mirror of
https://github.com/9001/copyparty.git
synced 2025-08-18 01:22:13 -06:00
If a file has no known extension the content type gets set to application/octet-stream causing the browser try and download the file when viewed directly. This quickly becomes annoying as many of the files I interact with often have no extension. I.e., config files, log files, LICENSE files and other random text files. This patch uses libmagic to detect the file type and set the content-type header. It also does this for the RSS feed and webdav for sake of completeness. This patch does not touch the front end at all so these files still have a 'txt' button and a type of '%' in the web UI. But when clicked on, the browser will display the files correctly. This feature is enabled with the existing "magic" option. I thought this fit as the existing functionality also uses libmagic and gives file extensions to files on upload. Tell me if it should be its own option instead. The code base was very confusing, this patch works but I have no idea if it's the way you'd like this implemented. Hopefully its acceptable as is.
6656 lines
225 KiB
Python
6656 lines
225 KiB
Python
# coding: utf-8
|
||
from __future__ import print_function, unicode_literals
|
||
|
||
import argparse # typechk
|
||
import copy
|
||
import errno
|
||
import hashlib
|
||
import itertools
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import socket
|
||
import stat
|
||
import string
|
||
import sys
|
||
import threading # typechk
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from operator import itemgetter
|
||
|
||
import jinja2 # typechk
|
||
from ipaddress import IPv6Network
|
||
|
||
try:
|
||
if os.environ.get("PRTY_NO_LZMA"):
|
||
raise Exception()
|
||
|
||
import lzma
|
||
except:
|
||
pass
|
||
|
||
from .__init__ import ANYWIN, PY2, RES, TYPE_CHECKING, EnvParams, unicode
|
||
from .__version__ import S_VERSION
|
||
from .authsrv import VFS # typechk
|
||
from .bos import bos
|
||
from .star import StreamTar
|
||
from .stolen.qrcodegen import QrCode, qr2svg
|
||
from .sutil import StreamArc, gfilter
|
||
from .szip import StreamZip
|
||
from .up2k import up2k_chunksize
|
||
from .util import unquote # type: ignore
|
||
from .util import (
|
||
APPLESAN_RE,
|
||
BITNESS,
|
||
DAV_ALLPROPS,
|
||
FN_EMB,
|
||
HAVE_SQLITE3,
|
||
HTTPCODE,
|
||
META_NOBOTS,
|
||
UTC,
|
||
Garda,
|
||
MultipartParser,
|
||
ODict,
|
||
Pebkac,
|
||
UnrecvEOF,
|
||
WrongPostKey,
|
||
absreal,
|
||
afsenc,
|
||
alltrace,
|
||
atomic_move,
|
||
b64dec,
|
||
exclude_dotfiles,
|
||
formatdate,
|
||
fsenc,
|
||
gen_filekey,
|
||
gen_filekey_dbg,
|
||
gencookie,
|
||
get_df,
|
||
get_spd,
|
||
guess_mime,
|
||
gzip,
|
||
gzip_file_orig_sz,
|
||
gzip_orig_sz,
|
||
has_resource,
|
||
hashcopy,
|
||
hidedir,
|
||
html_bescape,
|
||
html_escape,
|
||
humansize,
|
||
ipnorm,
|
||
justcopy,
|
||
load_resource,
|
||
loadpy,
|
||
log_reloc,
|
||
min_ex,
|
||
pathmod,
|
||
quotep,
|
||
rand_name,
|
||
read_header,
|
||
read_socket,
|
||
read_socket_chunked,
|
||
read_socket_unbounded,
|
||
read_utf8,
|
||
relchk,
|
||
ren_open,
|
||
runhook,
|
||
s2hms,
|
||
s3enc,
|
||
sanitize_fn,
|
||
sanitize_vpath,
|
||
sendfile_kern,
|
||
sendfile_py,
|
||
stat_resource,
|
||
ub64dec,
|
||
ub64enc,
|
||
ujoin,
|
||
undot,
|
||
unescape_cookie,
|
||
unquotep,
|
||
vjoin,
|
||
vol_san,
|
||
vroots,
|
||
vsplit,
|
||
wunlink,
|
||
yieldfile,
|
||
)
|
||
|
||
if True: # pylint: disable=using-constant-test
|
||
import typing
|
||
from typing import Any, Generator, Iterable, Match, Optional, Pattern, Type, Union
|
||
|
||
if TYPE_CHECKING:
|
||
from .httpconn import HttpConn
|
||
|
||
if not hasattr(socket, "AF_UNIX"):
|
||
setattr(socket, "AF_UNIX", -9001)
|
||
|
||
_ = (argparse, threading)
|
||
|
||
USED4SEC = {"usedforsecurity": False} if sys.version_info > (3, 9) else {}
|
||
|
||
NO_CACHE = {"Cache-Control": "no-cache"}
|
||
|
||
ALL_COOKIES = "k304 no304 js idxh dots cppwd cppws".split()
|
||
|
||
BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy)"
|
||
|
||
H_CONN_KEEPALIVE = "Connection: Keep-Alive"
|
||
H_CONN_CLOSE = "Connection: Close"
|
||
|
||
LOGUES = [[0, ".prologue.html"], [1, ".epilogue.html"]]
|
||
|
||
READMES = [[0, ["preadme.md", "PREADME.md"]], [1, ["readme.md", "README.md"]]]
|
||
|
||
RSS_SORT = {"m": "mt", "u": "at", "n": "fn", "s": "sz"}
|
||
|
||
A_FILE = os.stat_result(
|
||
(0o644, -1, -1, 1, 1000, 1000, 8, 0x39230101, 0x39230101, 0x39230101)
|
||
)
|
||
|
||
RE_CC = re.compile(r"[\x00-\x1f]") # search always faster
|
||
RE_HSAFE = re.compile(r"[\x00-\x1f<>\"'&]") # search always much faster
|
||
RE_HOST = re.compile(r"[^][0-9a-zA-Z.:_-]") # search faster <=17ch
|
||
RE_MHOST = re.compile(r"^[][0-9a-zA-Z.:_-]+$") # match faster >=18ch
|
||
RE_K = re.compile(r"[^0-9a-zA-Z_-]") # search faster <=17ch
|
||
RE_HR = re.compile(r"[<>\"'&]")
|
||
RE_MDV = re.compile(r"(.*)\.([0-9]+\.[0-9]{3})(\.[Mm][Dd])$")
|
||
|
||
UPARAM_CC_OK = set("doc move tree".split())
|
||
|
||
|
||
class HttpCli(object):
|
||
"""
|
||
Spawned by HttpConn to process one http transaction
|
||
"""
|
||
|
||
def __init__(self, conn: "HttpConn") -> None:
|
||
assert conn.sr # !rm
|
||
|
||
empty_stringlist: list[str] = []
|
||
|
||
self.t0 = time.time()
|
||
self.conn = conn
|
||
self.u2mutex = conn.u2mutex # mypy404
|
||
self.s = conn.s
|
||
self.sr = conn.sr
|
||
self.ip = conn.addr[0]
|
||
self.addr: tuple[str, int] = conn.addr
|
||
self.args = conn.args # mypy404
|
||
self.E: EnvParams = self.args.E
|
||
self.asrv = conn.asrv # mypy404
|
||
self.ico = conn.ico # mypy404
|
||
self.thumbcli = conn.thumbcli # mypy404
|
||
self.u2fh = conn.u2fh # mypy404
|
||
self.pipes = conn.pipes # mypy404
|
||
self.log_func = conn.log_func # mypy404
|
||
self.log_src = conn.log_src # mypy404
|
||
self.gen_fk = self._gen_fk if self.args.log_fk else gen_filekey
|
||
self.tls: bool = hasattr(self.s, "cipher")
|
||
self.is_vproxied = bool(self.args.R)
|
||
|
||
# placeholders; assigned by run()
|
||
self.keepalive = False
|
||
self.is_https = False
|
||
self.in_hdr_recv = True
|
||
self.headers: dict[str, str] = {}
|
||
self.mode = " " # http verb
|
||
self.req = " "
|
||
self.http_ver = ""
|
||
self.hint = ""
|
||
self.host = " "
|
||
self.ua = " "
|
||
self.is_rclone = False
|
||
self.ouparam: dict[str, str] = {}
|
||
self.uparam: dict[str, str] = {}
|
||
self.cookies: dict[str, str] = {}
|
||
self.avn: Optional[VFS] = None
|
||
self.vn = self.asrv.vfs
|
||
self.rem = " "
|
||
self.vpath = " "
|
||
self.vpaths = " "
|
||
self.dl_id = ""
|
||
self.gctx = " " # additional context for garda
|
||
self.trailing_slash = True
|
||
self.uname = " "
|
||
self.pw = " "
|
||
self.rvol = self.wvol = self.avol = empty_stringlist
|
||
self.do_log = True
|
||
self.can_read = False
|
||
self.can_write = False
|
||
self.can_move = False
|
||
self.can_delete = False
|
||
self.can_get = False
|
||
self.can_upget = False
|
||
self.can_admin = False
|
||
self.can_dot = False
|
||
self.out_headerlist: list[tuple[str, str]] = []
|
||
self.out_headers: dict[str, str] = {}
|
||
# post
|
||
self.parser: Optional[MultipartParser] = None
|
||
# end placeholders
|
||
|
||
self.html_head = ""
|
||
|
||
def log(self, msg: str, c: Union[int, str] = 0) -> None:
|
||
ptn = self.asrv.re_pwd
|
||
if ptn and ptn.search(msg):
|
||
if self.asrv.ah.on:
|
||
msg = ptn.sub("\033[7m pw \033[27m", msg)
|
||
else:
|
||
msg = ptn.sub(self.unpwd, msg)
|
||
|
||
self.log_func(self.log_src, msg, c)
|
||
|
||
def unpwd(self, m: Match[str]) -> str:
|
||
a, b, c = m.groups()
|
||
uname = self.asrv.iacct.get(b) or self.asrv.sesa.get(b)
|
||
return "%s\033[7m %s \033[27m%s" % (a, uname, c)
|
||
|
||
def _check_nonfatal(self, ex: Pebkac, post: bool) -> bool:
|
||
if post:
|
||
return ex.code < 300
|
||
|
||
return ex.code < 400 or ex.code in [404, 429]
|
||
|
||
def _assert_safe_rem(self, rem: str) -> None:
|
||
# sanity check to prevent any disasters
|
||
if rem.startswith("/") or rem.startswith("../") or "/../" in rem:
|
||
raise Exception("that was close")
|
||
|
||
def _gen_fk(self, alg: int, salt: str, fspath: str, fsize: int, inode: int) -> str:
|
||
return gen_filekey_dbg(
|
||
alg, salt, fspath, fsize, inode, self.log, self.args.log_fk
|
||
)
|
||
|
||
def j2s(self, name: str, **ka: Any) -> str:
|
||
tpl = self.conn.hsrv.j2[name]
|
||
ka["r"] = self.args.SR if self.is_vproxied else ""
|
||
ka["ts"] = self.conn.hsrv.cachebuster()
|
||
ka["lang"] = self.args.lang
|
||
ka["favico"] = self.args.favico
|
||
ka["s_doctitle"] = self.args.doctitle
|
||
ka["tcolor"] = self.vn.flags["tcolor"]
|
||
|
||
if self.args.js_other and "js" not in ka:
|
||
zs = self.args.js_other
|
||
zs += "&" if "?" in zs else "?"
|
||
ka["js"] = zs
|
||
|
||
zso = self.vn.flags.get("html_head")
|
||
if zso:
|
||
ka["this"] = self
|
||
self._build_html_head(zso, ka)
|
||
|
||
ka["html_head"] = self.html_head
|
||
return tpl.render(**ka) # type: ignore
|
||
|
||
def j2j(self, name: str) -> jinja2.Template:
|
||
return self.conn.hsrv.j2[name]
|
||
|
||
def run(self) -> bool:
|
||
"""returns true if connection can be reused"""
|
||
self.out_headers = {
|
||
"Vary": "Origin, PW, Cookie",
|
||
"Cache-Control": "no-store, max-age=0",
|
||
}
|
||
|
||
if self.args.early_ban and self.is_banned():
|
||
return False
|
||
|
||
if self.conn.ipa_nm and not self.conn.ipa_nm.map(self.conn.addr[0]):
|
||
self.log("client rejected (--ipa)", 3)
|
||
self.terse_reply(b"", 500)
|
||
return False
|
||
|
||
try:
|
||
self.s.settimeout(2)
|
||
headerlines = read_header(self.sr, self.args.s_thead, self.args.s_thead)
|
||
self.in_hdr_recv = False
|
||
if not headerlines:
|
||
return False
|
||
|
||
if not headerlines[0]:
|
||
# seen after login with IE6.0.2900.5512.xpsp.080413-2111 (xp-sp3)
|
||
self.log("BUG: trailing newline from previous request", c="1;31")
|
||
headerlines.pop(0)
|
||
|
||
try:
|
||
self.mode, self.req, self.http_ver = headerlines[0].split(" ")
|
||
|
||
# normalize incoming headers to lowercase;
|
||
# outgoing headers however are Correct-Case
|
||
for header_line in headerlines[1:]:
|
||
k, zs = header_line.split(":", 1)
|
||
self.headers[k.lower()] = zs.strip()
|
||
except:
|
||
msg = "#[ " + " ]\n#[ ".join(headerlines) + " ]"
|
||
raise Pebkac(400, "bad headers", log=msg)
|
||
|
||
except Pebkac as ex:
|
||
self.mode = "GET"
|
||
self.req = "[junk]"
|
||
self.http_ver = "HTTP/1.1"
|
||
# self.log("pebkac at httpcli.run #1: " + repr(ex))
|
||
self.keepalive = False
|
||
h = {"WWW-Authenticate": 'Basic realm="a"'} if ex.code == 401 else {}
|
||
try:
|
||
self.loud_reply(unicode(ex), status=ex.code, headers=h, volsan=True)
|
||
except:
|
||
pass
|
||
|
||
if ex.log:
|
||
self.log("additional error context:\n" + ex.log, 6)
|
||
|
||
return False
|
||
|
||
self.conn.hsrv.nreq += 1
|
||
|
||
self.ua = self.headers.get("user-agent", "")
|
||
self.is_rclone = self.ua.startswith("rclone/")
|
||
|
||
zs = self.headers.get("connection", "").lower()
|
||
self.keepalive = "close" not in zs and (
|
||
self.http_ver != "HTTP/1.0" or zs == "keep-alive"
|
||
)
|
||
self.is_https = (
|
||
self.headers.get("x-forwarded-proto", "").lower() == "https" or self.tls
|
||
)
|
||
self.host = self.headers.get("host") or ""
|
||
if not self.host:
|
||
if self.s.family == socket.AF_UNIX:
|
||
self.host = self.args.name
|
||
else:
|
||
zs = "%s:%s" % self.s.getsockname()[:2]
|
||
self.host = zs[7:] if zs.startswith("::ffff:") else zs
|
||
|
||
trusted_xff = False
|
||
n = self.args.rproxy
|
||
if n:
|
||
zso = self.headers.get(self.args.xff_hdr)
|
||
if zso:
|
||
if n > 0:
|
||
n -= 1
|
||
|
||
zsl = zso.split(",")
|
||
try:
|
||
cli_ip = zsl[n].strip()
|
||
except:
|
||
cli_ip = zsl[0].strip()
|
||
t = "rproxy={} oob x-fwd {}"
|
||
self.log(t.format(self.args.rproxy, zso), c=3)
|
||
|
||
pip = self.conn.addr[0]
|
||
xffs = self.conn.xff_nm
|
||
if xffs and not xffs.map(pip):
|
||
t = 'got header "%s" from untrusted source "%s" claiming the true client ip is "%s" (raw value: "%s"); if you trust this, you must allowlist this proxy with "--xff-src=%s"%s'
|
||
if self.headers.get("cf-connecting-ip"):
|
||
t += ' Note: if you are behind cloudflare, then this default header is not a good choice; please first make sure your local reverse-proxy (if any) does not allow non-cloudflare IPs from providing cf-* headers, and then add this additional global setting: "--xff-hdr=cf-connecting-ip"'
|
||
else:
|
||
t += ' Note: depending on your reverse-proxy, and/or WAF, and/or other intermediates, you may want to read the true client IP from another header by also specifying "--xff-hdr=SomeOtherHeader"'
|
||
|
||
if "." in pip:
|
||
zs = ".".join(pip.split(".")[:2]) + ".0.0/16"
|
||
else:
|
||
zs = IPv6Network(pip + "/64", False).compressed
|
||
|
||
zs2 = ' or "--xff-src=lan"' if self.conn.xff_lan.map(pip) else ""
|
||
self.log(t % (self.args.xff_hdr, pip, cli_ip, zso, zs, zs2), 3)
|
||
self.bad_xff = True
|
||
else:
|
||
self.ip = cli_ip
|
||
self.log_src = self.conn.set_rproxy(self.ip)
|
||
self.host = self.headers.get("x-forwarded-host") or self.host
|
||
trusted_xff = True
|
||
|
||
m = RE_HOST.search(self.host)
|
||
if m and self.host != self.args.name:
|
||
zs = self.host
|
||
t = "malicious user; illegal Host header; req(%r) host(%r) => %r"
|
||
self.log(t % (self.req, zs, zs[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, zs, "bad_host", "illegal Host header")
|
||
self.terse_reply(b"illegal Host header", 400)
|
||
return False
|
||
|
||
if self.is_banned():
|
||
return False
|
||
|
||
if self.conn.aclose:
|
||
nka = self.conn.aclose
|
||
ip = ipnorm(self.ip)
|
||
if ip in nka:
|
||
rt = nka[ip] - time.time()
|
||
if rt < 0:
|
||
self.log("client uncapped", 3)
|
||
del nka[ip]
|
||
else:
|
||
self.keepalive = False
|
||
|
||
ptn: Optional[Pattern[str]] = self.conn.lf_url # mypy404
|
||
self.do_log = not ptn or not ptn.search(self.req)
|
||
|
||
if self.args.ihead and self.do_log:
|
||
keys = self.args.ihead
|
||
if "*" in keys:
|
||
keys = list(sorted(self.headers.keys()))
|
||
|
||
for k in keys:
|
||
zso = self.headers.get(k)
|
||
if zso is not None:
|
||
self.log("[H] {}: \033[33m[{}]".format(k, zso), 6)
|
||
|
||
if "&" in self.req and "?" not in self.req:
|
||
self.hint = "did you mean '?' instead of '&'"
|
||
|
||
if self.args.uqe and "/.uqe/" in self.req:
|
||
try:
|
||
vpath, query = self.req.split("?")[0].split("/.uqe/")
|
||
query = query.split("/")[0] # discard trailing junk
|
||
# (usually a "filename" to trick discord into behaving)
|
||
query = ub64dec(query.encode("utf-8")).decode("utf-8", "replace")
|
||
if query.startswith("/"):
|
||
self.req = "%s/?%s" % (vpath, query[1:])
|
||
else:
|
||
self.req = "%s?%s" % (vpath, query)
|
||
except Exception as ex:
|
||
t = "bad uqe in request [%s]: %r" % (self.req, ex)
|
||
self.loud_reply(t, status=400)
|
||
return False
|
||
|
||
ptn_cc = RE_CC
|
||
m = ptn_cc.search(self.req)
|
||
if m:
|
||
zs = self.req
|
||
t = "malicious user; Cc in req0 %r => %r"
|
||
self.log(t % (zs, zs[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, zs, "cc_r0", "Cc in req0")
|
||
self.terse_reply(b"", 500)
|
||
return False
|
||
|
||
# split req into vpath + uparam
|
||
uparam = {}
|
||
if "?" not in self.req:
|
||
vpath = unquotep(self.req) # not query, so + means +
|
||
self.trailing_slash = vpath.endswith("/")
|
||
vpath = undot(vpath)
|
||
else:
|
||
vpath, arglist = self.req.split("?", 1)
|
||
vpath = unquotep(vpath)
|
||
self.trailing_slash = vpath.endswith("/")
|
||
vpath = undot(vpath)
|
||
|
||
re_k = RE_K
|
||
k_safe = UPARAM_CC_OK
|
||
for k in arglist.split("&"):
|
||
if "=" in k:
|
||
k, zs = k.split("=", 1)
|
||
# x-www-form-urlencoded (url query part) uses
|
||
# either + or %20 for 0x20 so handle both
|
||
sv = unquotep(zs.strip().replace("+", " "))
|
||
else:
|
||
sv = ""
|
||
|
||
m = re_k.search(k)
|
||
if m:
|
||
t = "malicious user; bad char in query key; req(%r) qk(%r) => %r"
|
||
self.log(t % (self.req, k, k[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, self.req, "bc_q", "illegal qkey")
|
||
self.terse_reply(b"", 500)
|
||
return False
|
||
|
||
k = k.lower()
|
||
uparam[k] = sv
|
||
|
||
if k in k_safe:
|
||
continue
|
||
|
||
zs = "%s=%s" % (k, sv)
|
||
m = ptn_cc.search(zs)
|
||
if not m:
|
||
continue
|
||
|
||
t = "malicious user; Cc in query; req(%r) qp(%r) => %r"
|
||
self.log(t % (self.req, zs, zs[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, self.req, "cc_q", "Cc in query")
|
||
self.terse_reply(b"", 500)
|
||
return False
|
||
|
||
if "k" in uparam:
|
||
m = re_k.search(uparam["k"])
|
||
if m:
|
||
zs = uparam["k"]
|
||
t = "malicious user; illegal filekey; req(%r) k(%r) => %r"
|
||
self.log(t % (self.req, zs, zs[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, zs, "bad_k", "illegal filekey")
|
||
self.terse_reply(b"illegal filekey", 400)
|
||
return False
|
||
|
||
if self.is_vproxied:
|
||
if vpath.startswith(self.args.R):
|
||
vpath = vpath[len(self.args.R) + 1 :]
|
||
else:
|
||
t = "incorrect --rp-loc or webserver config; expected vpath starting with %r but got %r"
|
||
self.log(t % (self.args.R, vpath), 1)
|
||
self.is_vproxied = False
|
||
|
||
self.ouparam = uparam.copy()
|
||
|
||
if self.args.rsp_slp:
|
||
time.sleep(self.args.rsp_slp)
|
||
if self.args.rsp_jtr:
|
||
time.sleep(random.random() * self.args.rsp_jtr)
|
||
|
||
zso = self.headers.get("cookie")
|
||
if zso:
|
||
if len(zso) > 8192:
|
||
self.loud_reply("cookie header too big", status=400)
|
||
return False
|
||
zsll = [x.split("=", 1) for x in zso.split(";") if "=" in x]
|
||
cookies = {k.strip(): unescape_cookie(zs) for k, zs in zsll}
|
||
cookie_pw = cookies.get("cppws") or cookies.get("cppwd") or ""
|
||
if "b" in cookies and "b" not in uparam:
|
||
uparam["b"] = cookies["b"]
|
||
else:
|
||
cookies = {}
|
||
cookie_pw = ""
|
||
|
||
if len(uparam) > 10 or len(cookies) > 50:
|
||
self.loud_reply("u wot m8", status=400)
|
||
return False
|
||
|
||
self.uparam = uparam
|
||
self.cookies = cookies
|
||
self.vpath = vpath
|
||
self.vpaths = (
|
||
self.vpath + "/" if self.trailing_slash and self.vpath else self.vpath
|
||
)
|
||
|
||
if "qr" in uparam:
|
||
return self.tx_qr()
|
||
|
||
if relchk(self.vpath) and (self.vpath != "*" or self.mode != "OPTIONS"):
|
||
self.log("illegal relpath; req(%r) => %r" % (self.req, "/" + self.vpath))
|
||
self.cbonk(self.conn.hsrv.gmal, self.req, "bad_vp", "invalid relpaths")
|
||
return self.tx_404() and self.keepalive
|
||
|
||
zso = self.headers.get("authorization")
|
||
bauth = ""
|
||
if (
|
||
zso
|
||
and not self.args.no_bauth
|
||
and (not cookie_pw or not self.args.bauth_last)
|
||
):
|
||
try:
|
||
zb = zso.split(" ")[1].encode("ascii")
|
||
zs = b64dec(zb).decode("utf-8")
|
||
# try "pwd", "x:pwd", "pwd:x"
|
||
for bauth in [zs] + zs.split(":", 1)[::-1]:
|
||
if bauth in self.asrv.sesa:
|
||
break
|
||
hpw = self.asrv.ah.hash(bauth)
|
||
if self.asrv.iacct.get(hpw):
|
||
break
|
||
except:
|
||
pass
|
||
|
||
self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw
|
||
self.uname = (
|
||
self.asrv.sesa.get(self.pw)
|
||
or self.asrv.iacct.get(self.asrv.ah.hash(self.pw))
|
||
or "*"
|
||
)
|
||
|
||
if self.args.idp_h_usr:
|
||
idp_usr = self.headers.get(self.args.idp_h_usr) or ""
|
||
if idp_usr:
|
||
idp_grp = (
|
||
self.headers.get(self.args.idp_h_grp) or ""
|
||
if self.args.idp_h_grp
|
||
else ""
|
||
)
|
||
|
||
if not trusted_xff:
|
||
pip = self.conn.addr[0]
|
||
xffs = self.conn.xff_nm
|
||
trusted_xff = xffs and xffs.map(pip)
|
||
|
||
trusted_key = (
|
||
not self.args.idp_h_key
|
||
) or self.args.idp_h_key in self.headers
|
||
|
||
if trusted_key and trusted_xff:
|
||
self.asrv.idp_checkin(self.conn.hsrv.broker, idp_usr, idp_grp)
|
||
else:
|
||
if not trusted_key:
|
||
t = 'the idp-h-key header ("%s") is not present in the request; will NOT trust the other headers saying that the client\'s username is "%s" and group is "%s"'
|
||
self.log(t % (self.args.idp_h_key, idp_usr, idp_grp), 3)
|
||
|
||
if not trusted_xff:
|
||
t = 'got IdP headers from untrusted source "%s" claiming the client\'s username is "%s" and group is "%s"; if you trust this, you must allowlist this proxy with "--xff-src=%s"%s'
|
||
if not self.args.idp_h_key:
|
||
t += " Note: you probably also want to specify --idp-h-key <SECRET-HEADER-NAME> for additional security"
|
||
|
||
pip = self.conn.addr[0]
|
||
zs = (
|
||
".".join(pip.split(".")[:2]) + "."
|
||
if "." in pip
|
||
else ":".join(pip.split(":")[:4]) + ":"
|
||
) + "0.0/16"
|
||
zs2 = (
|
||
' or "--xff-src=lan"' if self.conn.xff_lan.map(pip) else ""
|
||
)
|
||
self.log(t % (pip, idp_usr, idp_grp, zs, zs2), 3)
|
||
|
||
idp_usr = "*"
|
||
idp_grp = ""
|
||
|
||
if idp_usr in self.asrv.vfs.aread:
|
||
self.pw = ""
|
||
self.uname = idp_usr
|
||
self.html_head += "<script>var is_idp=1</script>\n"
|
||
else:
|
||
self.log("unknown username: %r" % (idp_usr,), 1)
|
||
|
||
if self.args.ipu and self.uname == "*":
|
||
self.uname = self.conn.ipu_iu[self.conn.ipu_nm.map(self.ip)]
|
||
|
||
self.rvol = self.asrv.vfs.aread[self.uname]
|
||
self.wvol = self.asrv.vfs.awrite[self.uname]
|
||
self.avol = self.asrv.vfs.aadmin[self.uname]
|
||
|
||
if self.pw and (
|
||
self.pw != cookie_pw or self.conn.freshen_pwd + 30 < time.time()
|
||
):
|
||
self.conn.freshen_pwd = time.time()
|
||
self.get_pwd_cookie(self.pw)
|
||
|
||
if self.is_rclone:
|
||
# dots: always include dotfiles if permitted
|
||
# lt: probably more important showing the correct timestamps of any dupes it just uploaded rather than the lastmod time of any non-copyparty-managed symlinks
|
||
# b: basic-browser if it tries to parse the html listing
|
||
uparam["dots"] = ""
|
||
uparam["lt"] = ""
|
||
uparam["b"] = ""
|
||
cookies["b"] = ""
|
||
|
||
vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False)
|
||
if "xdev" in vn.flags or "xvol" in vn.flags:
|
||
ap = vn.canonical(rem)
|
||
avn = vn.chk_ap(ap)
|
||
else:
|
||
avn = vn
|
||
|
||
(
|
||
self.can_read,
|
||
self.can_write,
|
||
self.can_move,
|
||
self.can_delete,
|
||
self.can_get,
|
||
self.can_upget,
|
||
self.can_admin,
|
||
self.can_dot,
|
||
) = (
|
||
avn.can_access("", self.uname) if avn else [False] * 8
|
||
)
|
||
self.avn = avn
|
||
self.vn = vn # note: do not dbv due to walk/zipgen
|
||
self.rem = rem
|
||
|
||
self.s.settimeout(self.args.s_tbody or None)
|
||
|
||
if "norobots" in vn.flags:
|
||
self.html_head += META_NOBOTS
|
||
self.out_headers["X-Robots-Tag"] = "noindex, nofollow"
|
||
|
||
try:
|
||
cors_k = self._cors()
|
||
if self.mode in ("GET", "HEAD"):
|
||
return self.handle_get() and self.keepalive
|
||
if self.mode == "OPTIONS":
|
||
return self.handle_options() and self.keepalive
|
||
|
||
if not cors_k:
|
||
host = self.headers.get("host", "<?>")
|
||
origin = self.headers.get("origin", "<?>")
|
||
proto = "https://" if self.is_https else "http://"
|
||
guess = "modifying" if (origin and host) else "stripping"
|
||
t = "cors-reject %s because request-header Origin=%r does not match request-protocol %r and host %r based on request-header Host=%r (note: if this request is not malicious, check if your reverse-proxy is accidentally %s request headers, in particular 'Origin', for example by running copyparty with --ihead='*' to show all request headers)"
|
||
self.log(t % (self.mode, origin, proto, self.host, host, guess), 3)
|
||
raise Pebkac(403, "rejected by cors-check")
|
||
|
||
# getattr(self.mode) is not yet faster than this
|
||
if self.mode == "POST":
|
||
return self.handle_post() and self.keepalive
|
||
elif self.mode == "PUT":
|
||
return self.handle_put() and self.keepalive
|
||
elif self.mode == "PROPFIND":
|
||
return self.handle_propfind() and self.keepalive
|
||
elif self.mode == "DELETE":
|
||
return self.handle_delete() and self.keepalive
|
||
elif self.mode == "PROPPATCH":
|
||
return self.handle_proppatch() and self.keepalive
|
||
elif self.mode == "LOCK":
|
||
return self.handle_lock() and self.keepalive
|
||
elif self.mode == "UNLOCK":
|
||
return self.handle_unlock() and self.keepalive
|
||
elif self.mode == "MKCOL":
|
||
return self.handle_mkcol() and self.keepalive
|
||
elif self.mode in ("MOVE", "COPY"):
|
||
return self.handle_cpmv() and self.keepalive
|
||
else:
|
||
raise Pebkac(400, 'invalid HTTP verb "{0}"'.format(self.mode))
|
||
|
||
except Exception as ex:
|
||
if not isinstance(ex, Pebkac):
|
||
pex = Pebkac(500)
|
||
else:
|
||
pex: Pebkac = ex # type: ignore
|
||
|
||
try:
|
||
if pex.code == 999:
|
||
self.terse_reply(b"", 500)
|
||
return False
|
||
|
||
post = self.mode in ["POST", "PUT"] or "content-length" in self.headers
|
||
if not self._check_nonfatal(pex, post):
|
||
self.keepalive = False
|
||
|
||
em = str(ex)
|
||
msg = em if pex is ex else min_ex()
|
||
|
||
if pex.code != 404 or self.do_log:
|
||
self.log(
|
||
"http%d: %s\033[0m, %r" % (pex.code, msg, "/" + self.vpath),
|
||
6 if em.startswith("client d/c ") else 3,
|
||
)
|
||
|
||
msg = "%s\r\nURL: %s\r\n" % (em, self.vpath)
|
||
if self.hint:
|
||
msg += "hint: %s\r\n" % (self.hint,)
|
||
|
||
if "database is locked" in em:
|
||
self.conn.hsrv.broker.say("log_stacks")
|
||
msg += "hint: important info in the server log\r\n"
|
||
|
||
zb = b"<pre>" + html_escape(msg).encode("utf-8", "replace")
|
||
h = {"WWW-Authenticate": 'Basic realm="a"'} if pex.code == 401 else {}
|
||
self.reply(zb, status=pex.code, headers=h, volsan=True)
|
||
if pex.log:
|
||
self.log("additional error context:\n" + pex.log, 6)
|
||
|
||
return self.keepalive
|
||
except Pebkac:
|
||
return False
|
||
|
||
finally:
|
||
if self.dl_id:
|
||
self.conn.hsrv.dli.pop(self.dl_id, None)
|
||
self.conn.hsrv.dls.pop(self.dl_id, None)
|
||
|
||
def dip(self) -> str:
|
||
if self.args.plain_ip:
|
||
return self.ip.replace(":", ".")
|
||
else:
|
||
return self.conn.iphash.s(self.ip)
|
||
|
||
def cbonk(self, g: Garda, v: str, reason: str, descr: str) -> bool:
|
||
self.conn.hsrv.nsus += 1
|
||
if not g.lim:
|
||
return False
|
||
|
||
bonk, ip = g.bonk(self.ip, v + self.gctx)
|
||
if not bonk:
|
||
return False
|
||
|
||
xban = self.vn.flags.get("xban")
|
||
if not xban or not runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xban",
|
||
xban,
|
||
self.vn.canonical(self.rem),
|
||
self.vpath,
|
||
self.host,
|
||
self.uname,
|
||
"",
|
||
time.time(),
|
||
0,
|
||
self.ip,
|
||
time.time(),
|
||
reason,
|
||
):
|
||
self.log("client banned: %s" % (descr,), 1)
|
||
self.conn.hsrv.bans[ip] = bonk
|
||
self.conn.hsrv.nban += 1
|
||
return True
|
||
|
||
return False
|
||
|
||
def is_banned(self) -> bool:
|
||
if not self.conn.bans:
|
||
return False
|
||
|
||
bans = self.conn.bans
|
||
ip = ipnorm(self.ip)
|
||
if ip not in bans:
|
||
return False
|
||
|
||
rt = bans[ip] - time.time()
|
||
if rt < 0:
|
||
self.log("client unbanned", 3)
|
||
del bans[ip]
|
||
return False
|
||
|
||
self.log("banned for {:.0f} sec".format(rt), 6)
|
||
self.terse_reply(b"thank you for playing", 403)
|
||
return True
|
||
|
||
def permit_caching(self) -> None:
|
||
cache = self.uparam.get("cache")
|
||
if cache is None:
|
||
self.out_headers.update(NO_CACHE)
|
||
return
|
||
|
||
n = 69 if not cache else 604869 if cache == "i" else int(cache)
|
||
self.out_headers["Cache-Control"] = "max-age=" + str(n)
|
||
|
||
def k304(self) -> bool:
|
||
k304 = self.cookies.get("k304")
|
||
return k304 == "y" or (self.args.k304 == 2 and k304 != "n")
|
||
|
||
def no304(self) -> bool:
|
||
no304 = self.cookies.get("no304")
|
||
return no304 == "y" or (self.args.no304 == 2 and no304 != "n")
|
||
|
||
def _build_html_head(self, maybe_html: Any, kv: dict[str, Any]) -> None:
|
||
html = str(maybe_html)
|
||
is_jinja = html[:2] in "%@%"
|
||
if is_jinja:
|
||
html = html.replace("%", "", 1)
|
||
|
||
if html.startswith("@"):
|
||
html = read_utf8(self.log, html[1:], True)
|
||
|
||
if html.startswith("%"):
|
||
html = html[1:]
|
||
is_jinja = True
|
||
|
||
if is_jinja:
|
||
with self.conn.hsrv.mutex:
|
||
if html not in self.conn.hsrv.j2:
|
||
j2env = jinja2.Environment()
|
||
tpl = j2env.from_string(html)
|
||
self.conn.hsrv.j2[html] = tpl
|
||
html = self.conn.hsrv.j2[html].render(**kv)
|
||
|
||
self.html_head += html + "\n"
|
||
|
||
def send_headers(
|
||
self,
|
||
length: Optional[int],
|
||
status: int = 200,
|
||
mime: Optional[str] = None,
|
||
headers: Optional[dict[str, str]] = None,
|
||
) -> None:
|
||
response = ["%s %s %s" % (self.http_ver, status, HTTPCODE[status])]
|
||
|
||
# headers{} overrides anything set previously
|
||
if headers:
|
||
self.out_headers.update(headers)
|
||
|
||
if status == 304:
|
||
self.out_headers.pop("Content-Length", None)
|
||
self.out_headers.pop("Content-Type", None)
|
||
self.out_headerlist.clear()
|
||
if self.k304():
|
||
self.keepalive = False
|
||
else:
|
||
if length is not None:
|
||
response.append("Content-Length: " + unicode(length))
|
||
|
||
if mime:
|
||
self.out_headers["Content-Type"] = mime
|
||
elif "Content-Type" not in self.out_headers:
|
||
self.out_headers["Content-Type"] = "text/html; charset=utf-8"
|
||
|
||
# close if unknown length, otherwise take client's preference
|
||
response.append(H_CONN_KEEPALIVE if self.keepalive else H_CONN_CLOSE)
|
||
response.append("Date: " + formatdate())
|
||
|
||
for k, zs in list(self.out_headers.items()) + self.out_headerlist:
|
||
response.append("%s: %s" % (k, zs))
|
||
|
||
ptn_cc = RE_CC
|
||
for zs in response:
|
||
m = ptn_cc.search(zs)
|
||
if m:
|
||
t = "malicious user; Cc in out-hdr; req(%r) hdr(%r) => %r"
|
||
self.log(t % (self.req, zs, zs[m.span()[0] :]), 1)
|
||
self.cbonk(self.conn.hsrv.gmal, zs, "cc_hdr", "Cc in out-hdr")
|
||
raise Pebkac(999)
|
||
|
||
if self.args.ohead and self.do_log:
|
||
keys = self.args.ohead
|
||
if "*" in keys:
|
||
lines = response[1:]
|
||
else:
|
||
lines = []
|
||
for zs in response[1:]:
|
||
if zs.split(":")[0].lower() in keys:
|
||
lines.append(zs)
|
||
for zs in lines:
|
||
hk, hv = zs.split(": ")
|
||
self.log("[O] {}: \033[33m[{}]".format(hk, hv), 5)
|
||
|
||
response.append("\r\n")
|
||
try:
|
||
self.s.sendall("\r\n".join(response).encode("utf-8"))
|
||
except:
|
||
raise Pebkac(400, "client d/c while replying headers")
|
||
|
||
def reply(
|
||
self,
|
||
body: bytes,
|
||
status: int = 200,
|
||
mime: Optional[str] = None,
|
||
headers: Optional[dict[str, str]] = None,
|
||
volsan: bool = False,
|
||
) -> bytes:
|
||
if (
|
||
status > 400
|
||
and status in (403, 404, 422)
|
||
and (
|
||
status != 422
|
||
or (
|
||
not body.startswith(b"<pre>partial upload exists")
|
||
and not body.startswith(b"<pre>source file busy")
|
||
)
|
||
)
|
||
and (status != 404 or (self.can_get and not self.can_read))
|
||
):
|
||
if status == 404:
|
||
g = self.conn.hsrv.g404
|
||
elif status == 403:
|
||
g = self.conn.hsrv.g403
|
||
else:
|
||
g = self.conn.hsrv.g422
|
||
|
||
gurl = self.conn.hsrv.gurl
|
||
if (
|
||
gurl.lim
|
||
and (not g.lim or gurl.lim < g.lim)
|
||
and self.args.sus_urls.search(self.vpath)
|
||
):
|
||
g = self.conn.hsrv.gurl
|
||
|
||
if g.lim and (
|
||
g == self.conn.hsrv.g422
|
||
or not self.args.nonsus_urls
|
||
or not self.args.nonsus_urls.search(self.vpath)
|
||
):
|
||
self.cbonk(g, self.vpath, str(status), "%ss" % (status,))
|
||
|
||
if volsan:
|
||
vols = list(self.asrv.vfs.all_vols.values())
|
||
body = vol_san(vols, body)
|
||
try:
|
||
zs = absreal(__file__).rsplit(os.path.sep, 2)[0]
|
||
body = body.replace(zs.encode("utf-8"), b"PP")
|
||
except:
|
||
pass
|
||
|
||
self.send_headers(len(body), status, mime, headers)
|
||
|
||
try:
|
||
if self.mode != "HEAD":
|
||
self.s.sendall(body)
|
||
except:
|
||
raise Pebkac(400, "client d/c while replying body")
|
||
|
||
return body
|
||
|
||
def loud_reply(self, body: str, *args: Any, **kwargs: Any) -> None:
|
||
if not kwargs.get("mime"):
|
||
kwargs["mime"] = "text/plain; charset=utf-8"
|
||
|
||
self.log(body.rstrip())
|
||
self.reply(body.encode("utf-8") + b"\r\n", *list(args), **kwargs)
|
||
|
||
def terse_reply(self, body: bytes, status: int = 200) -> None:
|
||
self.keepalive = False
|
||
|
||
lines = [
|
||
"%s %s %s" % (self.http_ver or "HTTP/1.1", status, HTTPCODE[status]),
|
||
H_CONN_CLOSE,
|
||
]
|
||
|
||
if body:
|
||
lines.append("Content-Length: " + unicode(len(body)))
|
||
|
||
lines.append("\r\n")
|
||
self.s.sendall("\r\n".join(lines).encode("utf-8") + body)
|
||
|
||
def urlq(self, add: dict[str, str], rm: list[str]) -> str:
|
||
"""
|
||
generates url query based on uparam (b, pw, all others)
|
||
removing anything in rm, adding pairs in add
|
||
|
||
also list faster than set until ~20 items
|
||
"""
|
||
|
||
if self.is_rclone:
|
||
return ""
|
||
|
||
kv = {k: zs for k, zs in self.uparam.items() if k not in rm}
|
||
if "pw" in kv:
|
||
pw = self.cookies.get("cppws") or self.cookies.get("cppwd")
|
||
if kv["pw"] == pw:
|
||
del kv["pw"]
|
||
|
||
kv.update(add)
|
||
if not kv:
|
||
return ""
|
||
|
||
r = ["%s=%s" % (quotep(k), quotep(zs)) if zs else k for k, zs in kv.items()]
|
||
return "?" + "&".join(r)
|
||
|
||
def ourlq(self) -> str:
|
||
skip = ("pw", "h", "k")
|
||
ret = []
|
||
for k, v in self.ouparam.items():
|
||
if k in skip:
|
||
continue
|
||
|
||
t = "%s=%s" % (quotep(k), quotep(v))
|
||
ret.append(t.replace(" ", "+").rstrip("="))
|
||
|
||
if not ret:
|
||
return ""
|
||
|
||
return "?" + "&".join(ret)
|
||
|
||
def redirect(
|
||
self,
|
||
vpath: str,
|
||
suf: str = "",
|
||
msg: str = "aight",
|
||
flavor: str = "go to",
|
||
click: bool = True,
|
||
status: int = 200,
|
||
use302: bool = False,
|
||
) -> bool:
|
||
vp = self.args.SRS + vpath
|
||
html = self.j2s(
|
||
"msg",
|
||
h2='<a href="{}">{} {}</a>'.format(
|
||
quotep(vp) + suf, flavor, html_escape(vp, crlf=True) + suf
|
||
),
|
||
pre=msg,
|
||
click=click,
|
||
).encode("utf-8", "replace")
|
||
|
||
if use302:
|
||
self.reply(html, status=302, headers={"Location": vp})
|
||
else:
|
||
self.reply(html, status=status)
|
||
|
||
return True
|
||
|
||
def _cors(self) -> bool:
|
||
ih = self.headers
|
||
origin = ih.get("origin")
|
||
if not origin:
|
||
sfsite = ih.get("sec-fetch-site")
|
||
if sfsite and sfsite.lower().startswith("cross"):
|
||
origin = ":|" # sandboxed iframe
|
||
else:
|
||
return True
|
||
|
||
oh = self.out_headers
|
||
origin = origin.lower()
|
||
good_origins = self.args.acao + [
|
||
"%s://%s"
|
||
% (
|
||
"https" if self.is_https else "http",
|
||
self.host.lower().split(":")[0],
|
||
)
|
||
]
|
||
if "pw" in ih or re.sub(r"(:[0-9]{1,5})?/?$", "", origin) in good_origins:
|
||
good_origin = True
|
||
bad_hdrs = ("",)
|
||
else:
|
||
good_origin = False
|
||
bad_hdrs = ("", "pw")
|
||
|
||
# '*' blocks auth through cookies / WWW-Authenticate;
|
||
# exact-match for Origin is necessary to unlock those,
|
||
# but the ?pw= param and PW: header are always allowed
|
||
acah = ih.get("access-control-request-headers", "")
|
||
acao = (origin if good_origin else None) or (
|
||
"*" if "*" in good_origins else None
|
||
)
|
||
if self.args.allow_csrf:
|
||
acao = origin or acao or "*" # explicitly permit impersonation
|
||
acam = ", ".join(self.conn.hsrv.mallow) # and all methods + headers
|
||
oh["Access-Control-Allow-Credentials"] = "true"
|
||
good_origin = True
|
||
else:
|
||
acam = ", ".join(self.args.acam)
|
||
# wash client-requested headers and roll with that
|
||
if "range" not in acah.lower():
|
||
acah += ",Range" # firefox
|
||
req_h = acah.split(",")
|
||
req_h = [x.strip() for x in req_h]
|
||
req_h = [x for x in req_h if x.lower() not in bad_hdrs]
|
||
acah = ", ".join(req_h)
|
||
|
||
if not acao:
|
||
return False
|
||
|
||
oh["Access-Control-Allow-Origin"] = acao
|
||
oh["Access-Control-Allow-Methods"] = acam.upper()
|
||
if acah:
|
||
oh["Access-Control-Allow-Headers"] = acah
|
||
|
||
return good_origin
|
||
|
||
def handle_get(self) -> bool:
|
||
if self.do_log:
|
||
logmsg = "%-4s %s @%s" % (self.mode, self.req, self.uname)
|
||
|
||
if "range" in self.headers:
|
||
try:
|
||
rval = self.headers["range"].split("=", 1)[1]
|
||
except:
|
||
rval = self.headers["range"]
|
||
|
||
logmsg += " [\033[36m" + rval + "\033[0m]"
|
||
|
||
self.log(logmsg)
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
# "embedded" resources
|
||
if self.vpath.startswith(".cpr"):
|
||
if self.vpath.startswith(".cpr/ico/"):
|
||
return self.tx_ico(self.vpath.split("/")[-1], exact=True)
|
||
|
||
if self.vpath.startswith(".cpr/ssdp"):
|
||
if self.conn.hsrv.ssdp:
|
||
return self.conn.hsrv.ssdp.reply(self)
|
||
else:
|
||
self.reply(b"ssdp is disabled in server config", 404)
|
||
return False
|
||
|
||
if self.vpath.startswith(".cpr/dd/") and self.args.mpmc:
|
||
if self.args.mpmc == ".":
|
||
raise Pebkac(404)
|
||
|
||
loc = self.args.mpmc.rstrip("/") + self.vpath[self.vpath.rfind("/") :]
|
||
h = {"Location": loc, "Cache-Control": "max-age=39"}
|
||
self.reply(b"", 301, headers=h)
|
||
return True
|
||
|
||
if self.vpath == ".cpr/metrics":
|
||
return self.conn.hsrv.metrics.tx(self)
|
||
|
||
res_path = "web/" + self.vpath[5:]
|
||
if res_path in RES:
|
||
ap = os.path.join(self.E.mod, res_path)
|
||
if bos.path.exists(ap) or bos.path.exists(ap + ".gz"):
|
||
return self.tx_file(ap)
|
||
else:
|
||
return self.tx_res(res_path)
|
||
|
||
self.tx_404()
|
||
return False
|
||
|
||
if "cf_challenge" in self.uparam:
|
||
self.reply(self.j2s("cf").encode("utf-8", "replace"))
|
||
return True
|
||
|
||
if not self.can_read and not self.can_write and not self.can_get:
|
||
t = "@%s has no access to %r"
|
||
|
||
if "on403" in self.vn.flags:
|
||
t += " (on403)"
|
||
self.log(t % (self.uname, "/" + self.vpath))
|
||
ret = self.on40x(self.vn.flags["on403"], self.vn, self.rem)
|
||
if ret == "true":
|
||
return True
|
||
elif ret == "false":
|
||
return False
|
||
elif ret == "home":
|
||
self.uparam["h"] = ""
|
||
elif ret == "allow":
|
||
self.log("plugin override; access permitted")
|
||
self.can_read = self.can_write = self.can_move = True
|
||
self.can_delete = self.can_get = self.can_upget = True
|
||
self.can_admin = True
|
||
else:
|
||
return self.tx_404(True)
|
||
else:
|
||
if (
|
||
self.asrv.badcfg1
|
||
and "h" not in self.ouparam
|
||
and "hc" not in self.ouparam
|
||
):
|
||
zs1 = "copyparty refused to start due to a failsafe: invalid server config; check server log"
|
||
zs2 = 'you may <a href="/?h">access the controlpanel</a> but nothing will work until you shutdown the copyparty container and %s config-file (or provide the configuration as command-line arguments)'
|
||
if self.asrv.is_lxc and len(self.asrv.cfg_files_loaded) == 1:
|
||
zs2 = zs2 % ("add a",)
|
||
else:
|
||
zs2 = zs2 % ("fix the",)
|
||
|
||
html = self.j2s("msg", h1=zs1, h2=zs2)
|
||
self.reply(html.encode("utf-8", "replace"), 500)
|
||
return True
|
||
|
||
if self.vpath:
|
||
ptn = self.args.nonsus_urls
|
||
if not ptn or not ptn.search(self.vpath):
|
||
self.log(t % (self.uname, "/" + self.vpath))
|
||
|
||
return self.tx_404(True)
|
||
|
||
self.uparam["h"] = ""
|
||
|
||
if "tree" in self.uparam:
|
||
return self.tx_tree()
|
||
|
||
if "scan" in self.uparam:
|
||
return self.scanvol()
|
||
|
||
if self.args.getmod:
|
||
if "delete" in self.uparam:
|
||
return self.handle_rm([])
|
||
|
||
if "move" in self.uparam:
|
||
return self.handle_mv()
|
||
|
||
if "copy" in self.uparam:
|
||
return self.handle_cp()
|
||
|
||
if not self.vpath and self.ouparam:
|
||
if "reload" in self.uparam:
|
||
return self.handle_reload()
|
||
|
||
if "stack" in self.uparam:
|
||
return self.tx_stack()
|
||
|
||
if "setck" in self.uparam:
|
||
return self.setck()
|
||
|
||
if "reset" in self.uparam:
|
||
return self.set_cfg_reset()
|
||
|
||
if "hc" in self.uparam:
|
||
return self.tx_svcs()
|
||
|
||
if "shares" in self.uparam:
|
||
return self.tx_shares()
|
||
|
||
if "dls" in self.uparam:
|
||
return self.tx_dls()
|
||
|
||
if "ru" in self.uparam:
|
||
return self.tx_rups()
|
||
|
||
if "h" in self.uparam:
|
||
return self.tx_mounts()
|
||
|
||
if "ups" in self.uparam:
|
||
# vpath is used for share translation
|
||
return self.tx_ups()
|
||
|
||
if "rss" in self.uparam:
|
||
return self.tx_rss()
|
||
|
||
return self.tx_browser()
|
||
|
||
def tx_rss(self) -> bool:
|
||
if self.do_log:
|
||
self.log("RSS %s @%s" % (self.req, self.uname))
|
||
|
||
if not self.can_read:
|
||
return self.tx_404(True)
|
||
|
||
vn = self.vn
|
||
if not vn.flags.get("rss"):
|
||
raise Pebkac(405, "RSS is disabled in server config")
|
||
|
||
rem = self.rem
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; rss is disabled")
|
||
raise Pebkac(500, "server busy, cannot generate rss; please retry in a bit")
|
||
|
||
uv = [rem]
|
||
if "recursive" in self.uparam:
|
||
uq = "up.rd like ?||'%'"
|
||
else:
|
||
uq = "up.rd == ?"
|
||
|
||
zs = str(self.uparam.get("fext", self.args.rss_fext))
|
||
if zs in ("True", "False"):
|
||
zs = ""
|
||
if zs:
|
||
zsl = []
|
||
for ext in zs.split(","):
|
||
zsl.append("+up.fn like '%.'||?")
|
||
uv.append(ext)
|
||
uq += " and ( %s )" % (" or ".join(zsl),)
|
||
|
||
zs1 = self.uparam.get("sort", self.args.rss_sort)
|
||
zs2 = zs1.lower()
|
||
zs = RSS_SORT.get(zs2)
|
||
if not zs:
|
||
raise Pebkac(400, "invalid sort key; must be m/u/n/s")
|
||
|
||
uq += " order by up." + zs
|
||
if zs1 == zs2:
|
||
uq += " desc"
|
||
|
||
nmax = int(self.uparam.get("nf") or self.args.rss_nf)
|
||
|
||
hits = idx.run_query(self.uname, [self.vn], uq, uv, False, False, nmax)[0]
|
||
|
||
pw = self.ouparam.get("pw")
|
||
if pw:
|
||
q_pw = "?pw=%s" % (html_escape(pw, True, True),)
|
||
a_pw = "&pw=%s" % (html_escape(pw, True, True),)
|
||
for i in hits:
|
||
i["rp"] += a_pw if "?" in i["rp"] else q_pw
|
||
else:
|
||
q_pw = a_pw = ""
|
||
|
||
title = self.uparam.get("title") or self.vpath.split("/")[-1]
|
||
etitle = html_escape(title, True, True)
|
||
|
||
baseurl = "%s://%s%s" % (
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
self.args.SRS,
|
||
)
|
||
feed = "%s%s" % (baseurl, self.req[1:])
|
||
efeed = html_escape(feed, True, True)
|
||
edirlink = efeed.split("?")[0] + q_pw
|
||
|
||
ret = [
|
||
"""\
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||
\t<channel>
|
||
\t\t<atom:link href="%s" rel="self" type="application/rss+xml" />
|
||
\t\t<title>%s</title>
|
||
\t\t<description></description>
|
||
\t\t<link>%s</link>
|
||
\t\t<generator>copyparty-1</generator>
|
||
"""
|
||
% (efeed, etitle, edirlink)
|
||
]
|
||
|
||
q = "select fn from cv where rd=? and dn=?"
|
||
crd, cdn = rem.rsplit("/", 1) if "/" in rem else ("", rem)
|
||
try:
|
||
cfn = idx.cur[self.vn.realpath].execute(q, (crd, cdn)).fetchone()[0]
|
||
bos.stat(os.path.join(vn.canonical(rem), cfn))
|
||
cv_url = "%s%s?th=jf%s" % (baseurl, vjoin(self.vpath, cfn), a_pw)
|
||
cv_url = html_escape(cv_url, True, True)
|
||
zs = """\
|
||
\t\t<image>
|
||
\t\t\t<url>%s</url>
|
||
\t\t\t<title>%s</title>
|
||
\t\t\t<link>%s</link>
|
||
\t\t</image>
|
||
"""
|
||
ret.append(zs % (cv_url, etitle, edirlink))
|
||
except:
|
||
pass
|
||
|
||
for i in hits:
|
||
f = fsenc(os.path.join(self.vn.realpath, i["rp"])) if "magic" in self.vn.flags else None
|
||
|
||
iurl = html_escape("%s%s" % (baseurl, i["rp"]), True, True)
|
||
title = unquotep(i["rp"].split("?")[0].split("/")[-1])
|
||
title = html_escape(title, True, True)
|
||
tag_t = str(i["tags"].get("title") or "")
|
||
tag_a = str(i["tags"].get("artist") or "")
|
||
desc = "%s - %s" % (tag_a, tag_t) if tag_t and tag_a else (tag_t or tag_a)
|
||
desc = html_escape(desc, True, True) if desc else title
|
||
mime = html_escape(guess_mime(title, f))
|
||
lmod = formatdate(max(0, i["ts"]))
|
||
zsa = (iurl, iurl, title, desc, lmod, iurl, mime, i["sz"])
|
||
zs = (
|
||
"""\
|
||
\t\t<item>
|
||
\t\t\t<guid>%s</guid>
|
||
\t\t\t<link>%s</link>
|
||
\t\t\t<title>%s</title>
|
||
\t\t\t<description>%s</description>
|
||
\t\t\t<pubDate>%s</pubDate>
|
||
\t\t\t<enclosure url="%s" type="%s" length="%d"/>
|
||
"""
|
||
% zsa
|
||
)
|
||
dur = i["tags"].get(".dur")
|
||
if dur:
|
||
zs += "\t\t\t<itunes:duration>%d</itunes:duration>\n" % (dur,)
|
||
ret.append(zs + "\t\t</item>\n")
|
||
|
||
ret.append("\t</channel>\n</rss>\n")
|
||
bret = "".join(ret).encode("utf-8", "replace")
|
||
self.reply(bret, 200, "text/xml; charset=utf-8")
|
||
self.log("rss: %d hits, %d bytes" % (len(hits), len(bret)))
|
||
return True
|
||
|
||
def handle_propfind(self) -> bool:
|
||
if self.do_log:
|
||
self.log("PFIND %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
vn = self.vn
|
||
rem = self.rem
|
||
tap = vn.canonical(rem)
|
||
|
||
if "davauth" in vn.flags and self.uname == "*":
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
from .dxml import parse_xml
|
||
|
||
# enc = "windows-31j"
|
||
# enc = "shift_jis"
|
||
enc = "utf-8"
|
||
uenc = enc.upper()
|
||
props = DAV_ALLPROPS
|
||
|
||
clen = int(self.headers.get("content-length", 0))
|
||
if clen:
|
||
buf = b""
|
||
for rbuf in self.get_body_reader()[0]:
|
||
buf += rbuf
|
||
if not rbuf or len(buf) >= 32768:
|
||
break
|
||
|
||
xroot = parse_xml(buf.decode(enc, "replace"))
|
||
xtag = next((x for x in xroot if x.tag.split("}")[-1] == "prop"), None)
|
||
if xtag is not None:
|
||
props = set([y.tag.split("}")[-1] for y in xtag])
|
||
# assume <allprop/> otherwise; nobody ever gonna <propname/>
|
||
|
||
zi = int(time.time())
|
||
vst = os.stat_result((16877, -1, -1, 1, 1000, 1000, 8, zi, zi, zi))
|
||
|
||
try:
|
||
st = bos.stat(tap)
|
||
except OSError as ex:
|
||
if ex.errno not in (errno.ENOENT, errno.ENOTDIR):
|
||
raise
|
||
raise Pebkac(404)
|
||
|
||
topdir = {"vp": "", "st": st}
|
||
fgen: Iterable[dict[str, Any]] = []
|
||
|
||
depth = self.headers.get("depth", "infinity").lower()
|
||
if depth == "infinity":
|
||
# allow depth:0 from unmapped root, but require read-axs otherwise
|
||
if not self.can_read and (self.vpath or self.asrv.vfs.realpath):
|
||
t = "depth:infinity requires read-access in %r"
|
||
t = t % ("/" + self.vpath,)
|
||
self.log(t, 3)
|
||
raise Pebkac(401, t)
|
||
|
||
if not stat.S_ISDIR(topdir["st"].st_mode):
|
||
t = "depth:infinity can only be used on folders; %r is 0o%o"
|
||
t = t % ("/" + self.vpath, topdir["st"])
|
||
self.log(t, 3)
|
||
raise Pebkac(400, t)
|
||
|
||
if not self.args.dav_inf:
|
||
self.log("client wants --dav-inf", 3)
|
||
zb = b'<?xml version="1.0" encoding="utf-8"?>\n<D:error xmlns:D="DAV:"><D:propfind-finite-depth/></D:error>'
|
||
self.reply(zb, 403, "application/xml; charset=utf-8")
|
||
return True
|
||
|
||
# this will return symlink-target timestamps
|
||
# because lstat=true would not recurse into subfolders
|
||
# and this is a rare case where we actually want that
|
||
fgen = vn.zipgen(
|
||
rem,
|
||
rem,
|
||
set(),
|
||
self.uname,
|
||
True,
|
||
not self.args.no_scandir,
|
||
wrap=False,
|
||
)
|
||
|
||
elif depth == "0" or not stat.S_ISDIR(st.st_mode):
|
||
# propfind on a file; return as topdir
|
||
if not self.can_read and not self.can_get:
|
||
self.log("inaccessible: %r" % ("/" + self.vpath,))
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
elif depth == "1":
|
||
_, vfs_ls, vfs_virt = vn.ls(
|
||
rem,
|
||
self.uname,
|
||
not self.args.no_scandir,
|
||
[[True, False]],
|
||
lstat="davrt" not in vn.flags,
|
||
throw=True,
|
||
)
|
||
if not self.can_read:
|
||
vfs_ls = []
|
||
if not self.can_dot:
|
||
names = set(exclude_dotfiles([x[0] for x in vfs_ls]))
|
||
vfs_ls = [x for x in vfs_ls if x[0] in names]
|
||
|
||
fgen = [{"vp": vp, "st": st} for vp, st in vfs_ls]
|
||
fgen += [{"vp": v, "st": vst} for v in vfs_virt]
|
||
|
||
else:
|
||
t = "invalid depth value '{}' (must be either '0' or '1'{})"
|
||
t2 = " or 'infinity'" if self.args.dav_inf else ""
|
||
raise Pebkac(412, t.format(depth, t2))
|
||
|
||
if not self.can_read and not self.can_write and not fgen:
|
||
self.log("inaccessible: %r" % ("/" + self.vpath,))
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
fgen = itertools.chain([topdir], fgen)
|
||
vtop = vjoin(self.args.R, vjoin(vn.vpath, rem))
|
||
|
||
chunksz = 0x7FF8 # preferred by nginx or cf (dunno which)
|
||
|
||
self.send_headers(
|
||
None, 207, "text/xml; charset=" + enc, {"Transfer-Encoding": "chunked"}
|
||
)
|
||
|
||
ret = '<?xml version="1.0" encoding="{}"?>\n<D:multistatus xmlns:D="DAV:">'
|
||
ret = ret.format(uenc)
|
||
for x in fgen:
|
||
rp = vjoin(vtop, x["vp"])
|
||
st: os.stat_result = x["st"]
|
||
mtime = max(0, st.st_mtime)
|
||
if stat.S_ISLNK(st.st_mode):
|
||
try:
|
||
st = bos.stat(os.path.join(tap, x["vp"]))
|
||
except:
|
||
continue
|
||
|
||
isdir = stat.S_ISDIR(st.st_mode)
|
||
|
||
ret += "<D:response><D:href>/%s%s</D:href><D:propstat><D:prop>" % (
|
||
quotep(rp),
|
||
"/" if isdir and rp else "",
|
||
)
|
||
|
||
pvs: dict[str, str] = {
|
||
"displayname": html_escape(rp.split("/")[-1]),
|
||
"getlastmodified": formatdate(mtime),
|
||
"resourcetype": '<D:collection xmlns:D="DAV:"/>' if isdir else "",
|
||
"supportedlock": '<D:lockentry xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockentry>',
|
||
}
|
||
if not isdir:
|
||
f = fsenc(os.path.join(tap, x["vp"])) if "magic" in self.vn.flags else None
|
||
|
||
pvs["getcontenttype"] = html_escape(guess_mime(rp), f)
|
||
pvs["getcontentlength"] = str(st.st_size)
|
||
|
||
for k, v in pvs.items():
|
||
if k not in props:
|
||
continue
|
||
elif v:
|
||
ret += "<D:%s>%s</D:%s>" % (k, v, k)
|
||
else:
|
||
ret += "<D:%s/>" % (k,)
|
||
|
||
ret += "</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>"
|
||
|
||
missing = ["<D:%s/>" % (x,) for x in props if x not in pvs]
|
||
if missing and clen:
|
||
t = "<D:propstat><D:prop>{}</D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>"
|
||
ret += t.format("".join(missing))
|
||
|
||
ret += "</D:response>"
|
||
while len(ret) >= chunksz:
|
||
ret = self.send_chunk(ret, enc, chunksz)
|
||
|
||
ret += "</D:multistatus>"
|
||
while ret:
|
||
ret = self.send_chunk(ret, enc, chunksz)
|
||
|
||
self.send_chunk("", enc, chunksz)
|
||
# self.reply(ret.encode(enc, "replace"),207, "text/xml; charset=" + enc)
|
||
return True
|
||
|
||
def handle_proppatch(self) -> bool:
|
||
if self.do_log:
|
||
self.log("PPATCH %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
if not self.can_write:
|
||
self.log("%s tried to proppatch %r" % (self.uname, "/" + self.vpath))
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from .dxml import mkenod, mktnod, parse_xml
|
||
|
||
buf = b""
|
||
for rbuf in self.get_body_reader()[0]:
|
||
buf += rbuf
|
||
if not rbuf or len(buf) >= 128 * 1024:
|
||
break
|
||
|
||
if self._applesan():
|
||
return True
|
||
|
||
txt = buf.decode("ascii", "replace").lower()
|
||
enc = self.get_xml_enc(txt)
|
||
uenc = enc.upper()
|
||
|
||
txt = buf.decode(enc, "replace")
|
||
ET.register_namespace("D", "DAV:")
|
||
xroot = mkenod("D:orz")
|
||
xroot.insert(0, parse_xml(txt))
|
||
xprop = xroot.find(r"./{DAV:}propertyupdate/{DAV:}set/{DAV:}prop")
|
||
assert xprop # !rm
|
||
for ze in xprop:
|
||
ze.clear()
|
||
|
||
txt = """<multistatus xmlns="DAV:"><response><propstat><status>HTTP/1.1 403 Forbidden</status></propstat></response></multistatus>"""
|
||
xroot = parse_xml(txt)
|
||
|
||
el = xroot.find(r"./{DAV:}response")
|
||
assert el # !rm
|
||
e2 = mktnod("D:href", quotep(self.args.SRS + self.vpath))
|
||
el.insert(0, e2)
|
||
|
||
el = xroot.find(r"./{DAV:}response/{DAV:}propstat")
|
||
assert el # !rm
|
||
el.insert(0, xprop)
|
||
|
||
ret = '<?xml version="1.0" encoding="{}"?>\n'.format(uenc)
|
||
ret += ET.tostring(xroot).decode("utf-8")
|
||
|
||
self.reply(ret.encode(enc, "replace"), 207, "text/xml; charset=" + enc)
|
||
return True
|
||
|
||
def handle_lock(self) -> bool:
|
||
if self.do_log:
|
||
self.log("LOCK %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
# win7+ deadlocks if we say no; just smile and nod
|
||
if not self.can_write and "Microsoft-WebDAV" not in self.ua:
|
||
self.log("%s tried to lock %r" % (self.uname, "/" + self.vpath))
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from .dxml import mkenod, mktnod, parse_xml
|
||
|
||
abspath = self.vn.dcanonical(self.rem)
|
||
|
||
buf = b""
|
||
for rbuf in self.get_body_reader()[0]:
|
||
buf += rbuf
|
||
if not rbuf or len(buf) >= 128 * 1024:
|
||
break
|
||
|
||
if self._applesan():
|
||
return True
|
||
|
||
txt = buf.decode("ascii", "replace").lower()
|
||
enc = self.get_xml_enc(txt)
|
||
uenc = enc.upper()
|
||
|
||
txt = buf.decode(enc, "replace")
|
||
ET.register_namespace("D", "DAV:")
|
||
lk = parse_xml(txt)
|
||
assert lk.tag == "{DAV:}lockinfo"
|
||
|
||
token = str(uuid.uuid4())
|
||
|
||
if lk.find(r"./{DAV:}depth") is None:
|
||
depth = self.headers.get("depth", "infinity")
|
||
lk.append(mktnod("D:depth", depth))
|
||
|
||
lk.append(mktnod("D:timeout", "Second-3310"))
|
||
lk.append(mkenod("D:locktoken", mktnod("D:href", token)))
|
||
lk.append(
|
||
mkenod("D:lockroot", mktnod("D:href", quotep(self.args.SRS + self.vpath)))
|
||
)
|
||
|
||
lk2 = mkenod("D:activelock")
|
||
xroot = mkenod("D:prop", mkenod("D:lockdiscovery", lk2))
|
||
for a in lk:
|
||
lk2.append(a)
|
||
|
||
ret = '<?xml version="1.0" encoding="{}"?>\n'.format(uenc)
|
||
ret += ET.tostring(xroot).decode("utf-8")
|
||
|
||
rc = 200
|
||
if self.can_write and not bos.path.isfile(abspath):
|
||
with open(fsenc(abspath), "wb") as _:
|
||
rc = 201
|
||
|
||
self.out_headers["Lock-Token"] = "<{}>".format(token)
|
||
self.reply(ret.encode(enc, "replace"), rc, "text/xml; charset=" + enc)
|
||
return True
|
||
|
||
def handle_unlock(self) -> bool:
|
||
if self.do_log:
|
||
self.log("UNLOCK %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
if not self.can_write and "Microsoft-WebDAV" not in self.ua:
|
||
self.log("%s tried to lock %r" % (self.uname, "/" + self.vpath))
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
self.send_headers(None, 204)
|
||
return True
|
||
|
||
def handle_mkcol(self) -> bool:
|
||
if self._applesan():
|
||
return True
|
||
|
||
if self.do_log:
|
||
self.log("MKCOL %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
if not self.can_write:
|
||
raise Pebkac(401, "authenticate")
|
||
|
||
try:
|
||
return self._mkdir(self.vpath, True)
|
||
except Pebkac as ex:
|
||
if ex.code >= 500:
|
||
raise
|
||
|
||
self.reply(b"", ex.code)
|
||
return True
|
||
|
||
def handle_cpmv(self) -> bool:
|
||
dst = self.headers["destination"]
|
||
|
||
# dolphin (kioworker/6.10) "webdav://127.0.0.1:3923/a/b.txt"
|
||
dst = re.sub("^[a-zA-Z]+://[^/]+", "", dst).lstrip()
|
||
|
||
if self.is_vproxied and dst.startswith(self.args.SRS):
|
||
dst = dst[len(self.args.RS) :]
|
||
|
||
if self.do_log:
|
||
self.log("%s %s --//> %s @%s" % (self.mode, self.req, dst, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.args.no_dav:
|
||
raise Pebkac(405, "WebDAV is disabled in server config")
|
||
|
||
dst = unquotep(dst)
|
||
|
||
# overwrite=True is default; rfc4918 9.8.4
|
||
zs = self.headers.get("overwrite", "").lower()
|
||
overwrite = zs not in ["f", "false"]
|
||
|
||
try:
|
||
fun = self._cp if self.mode == "COPY" else self._mv
|
||
return fun(self.vpath, dst.lstrip("/"), overwrite)
|
||
except Pebkac as ex:
|
||
if ex.code == 403:
|
||
ex.code = 401
|
||
raise
|
||
|
||
def _applesan(self) -> bool:
|
||
if self.args.dav_mac or "Darwin/" not in self.ua:
|
||
return False
|
||
|
||
vp = "/" + self.vpath
|
||
if re.search(APPLESAN_RE, vp):
|
||
zt = '<?xml version="1.0" encoding="utf-8"?>\n<D:error xmlns:D="DAV:"><D:lock-token-submitted><D:href>{}</D:href></D:lock-token-submitted></D:error>'
|
||
zb = zt.format(vp).encode("utf-8", "replace")
|
||
self.reply(zb, 423, "text/xml; charset=utf-8")
|
||
return True
|
||
|
||
return False
|
||
|
||
def send_chunk(self, txt: str, enc: str, bmax: int) -> str:
|
||
orig_len = len(txt)
|
||
buf = txt[:bmax].encode(enc, "replace")[:bmax]
|
||
try:
|
||
_ = buf.decode(enc)
|
||
except UnicodeDecodeError as ude:
|
||
buf = buf[: ude.start]
|
||
|
||
txt = txt[len(buf.decode(enc)) :]
|
||
if txt and len(txt) == orig_len:
|
||
raise Pebkac(500, "chunk slicing failed")
|
||
|
||
buf = ("%x\r\n" % (len(buf),)).encode(enc) + buf
|
||
self.s.sendall(buf + b"\r\n")
|
||
return txt
|
||
|
||
def handle_options(self) -> bool:
|
||
if self.do_log:
|
||
self.log("OPTIONS %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
oh = self.out_headers
|
||
oh["Allow"] = ", ".join(self.conn.hsrv.mallow)
|
||
|
||
if not self.args.no_dav:
|
||
# PROPPATCH, LOCK, UNLOCK, COPY: noop (spec-must)
|
||
oh["Dav"] = "1, 2"
|
||
oh["Ms-Author-Via"] = "DAV"
|
||
|
||
# winxp-webdav doesnt know what 204 is
|
||
self.send_headers(0, 200)
|
||
return True
|
||
|
||
def handle_delete(self) -> bool:
|
||
self.log("DELETE %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
return self.handle_rm([])
|
||
|
||
def handle_put(self) -> bool:
|
||
self.log("PUT %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if not self.can_write:
|
||
t = "user %s does not have write-access under /%s"
|
||
raise Pebkac(403 if self.pw else 401, t % (self.uname, self.vn.vpath))
|
||
|
||
if not self.args.no_dav and self._applesan():
|
||
return self.headers.get("content-length") == "0"
|
||
|
||
if self.headers.get("expect", "").lower() == "100-continue":
|
||
try:
|
||
self.s.sendall(b"HTTP/1.1 100 Continue\r\n\r\n")
|
||
except:
|
||
raise Pebkac(400, "client d/c before 100 continue")
|
||
|
||
return self.handle_stash(True)
|
||
|
||
def handle_post(self) -> bool:
|
||
self.log("POST %s @%s" % (self.req, self.uname))
|
||
if "%" in self.req:
|
||
self.log(" `-- %r" % (self.vpath,))
|
||
|
||
if self.headers.get("expect", "").lower() == "100-continue":
|
||
try:
|
||
self.s.sendall(b"HTTP/1.1 100 Continue\r\n\r\n")
|
||
except:
|
||
raise Pebkac(400, "client d/c before 100 continue")
|
||
|
||
if "raw" in self.uparam:
|
||
return self.handle_stash(False)
|
||
|
||
ctype = self.headers.get("content-type", "").lower()
|
||
|
||
if "multipart/form-data" in ctype:
|
||
return self.handle_post_multipart()
|
||
|
||
if (
|
||
"application/json" in ctype
|
||
or "text/plain" in ctype
|
||
or "application/xml" in ctype
|
||
):
|
||
return self.handle_post_json()
|
||
|
||
if "move" in self.uparam:
|
||
return self.handle_mv()
|
||
|
||
if "copy" in self.uparam:
|
||
return self.handle_cp()
|
||
|
||
if "delete" in self.uparam:
|
||
return self.handle_rm([])
|
||
|
||
if "eshare" in self.uparam:
|
||
return self.handle_eshare()
|
||
|
||
if "application/octet-stream" in ctype:
|
||
return self.handle_post_binary()
|
||
|
||
if "application/x-www-form-urlencoded" in ctype:
|
||
opt = self.args.urlform
|
||
if "stash" in opt:
|
||
return self.handle_stash(False)
|
||
|
||
xm = []
|
||
xm_rsp = {}
|
||
|
||
if "save" in opt:
|
||
post_sz, _, _, _, _, path, _ = self.dump_to_file(False)
|
||
self.log("urlform: %d bytes, %r" % (post_sz, path))
|
||
elif "print" in opt:
|
||
reader, _ = self.get_body_reader()
|
||
buf = b""
|
||
for rbuf in reader:
|
||
buf += rbuf
|
||
if not rbuf or len(buf) >= 32768:
|
||
break
|
||
|
||
if buf:
|
||
orig = buf.decode("utf-8", "replace")
|
||
t = "urlform_raw %d @ %r\n %r\n"
|
||
self.log(t % (len(orig), "/" + self.vpath, orig))
|
||
try:
|
||
zb = unquote(buf.replace(b"+", b" "))
|
||
plain = zb.decode("utf-8", "replace")
|
||
if buf.startswith(b"msg="):
|
||
plain = plain[4:]
|
||
xm = self.vn.flags.get("xm")
|
||
if xm:
|
||
xm_rsp = runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xm",
|
||
xm,
|
||
self.vn.canonical(self.rem),
|
||
self.vpath,
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(self.vpath, self.uname),
|
||
time.time(),
|
||
len(buf),
|
||
self.ip,
|
||
time.time(),
|
||
plain,
|
||
)
|
||
|
||
t = "urlform_dec %d @ %r\n %r\n"
|
||
self.log(t % (len(plain), "/" + self.vpath, plain))
|
||
|
||
except Exception as ex:
|
||
self.log(repr(ex))
|
||
|
||
if "xm" in opt:
|
||
if xm:
|
||
self.loud_reply(xm_rsp.get("stdout") or "", status=202)
|
||
return True
|
||
else:
|
||
return self.handle_get()
|
||
|
||
if "get" in opt:
|
||
return self.handle_get()
|
||
|
||
raise Pebkac(405, "POST({}) is disabled in server config".format(ctype))
|
||
|
||
raise Pebkac(405, "don't know how to handle POST({})".format(ctype))
|
||
|
||
def get_xml_enc(self, txt: str) -> str:
|
||
ofs = txt[:512].find(' encoding="')
|
||
enc = ""
|
||
if ofs + 1:
|
||
enc = txt[ofs + 6 :].split('"')[1]
|
||
else:
|
||
enc = self.headers.get("content-type", "").lower()
|
||
ofs = enc.find("charset=")
|
||
if ofs + 1:
|
||
enc = enc[ofs + 4].split("=")[1].split(";")[0].strip("\"'")
|
||
else:
|
||
enc = ""
|
||
|
||
return enc or "utf-8"
|
||
|
||
def get_body_reader(self) -> tuple[Generator[bytes, None, None], int]:
|
||
bufsz = self.args.s_rd_sz
|
||
if "chunked" in self.headers.get("transfer-encoding", "").lower():
|
||
return read_socket_chunked(self.sr, bufsz), -1
|
||
|
||
remains = int(self.headers.get("content-length", -1))
|
||
if remains == -1:
|
||
self.keepalive = False
|
||
self.in_hdr_recv = True
|
||
self.s.settimeout(max(self.args.s_tbody // 20, 1))
|
||
return read_socket_unbounded(self.sr, bufsz), remains
|
||
else:
|
||
return read_socket(self.sr, bufsz, remains), remains
|
||
|
||
def dump_to_file(self, is_put: bool) -> tuple[int, str, str, str, int, str, str]:
|
||
# post_sz, halg, sha_hex, sha_b64, remains, path, url
|
||
reader, remains = self.get_body_reader()
|
||
vfs, rem = self.asrv.vfs.get(self.vpath, self.uname, False, True)
|
||
rnd, lifetime, xbu, xau = self.upload_flags(vfs)
|
||
lim = vfs.get_dbv(rem)[0].lim
|
||
fdir = vfs.canonical(rem)
|
||
if lim:
|
||
fdir, rem = lim.all(
|
||
self.ip, rem, remains, vfs.realpath, fdir, self.conn.hsrv.broker
|
||
)
|
||
|
||
fn = None
|
||
if rem and not self.trailing_slash and not bos.path.isdir(fdir):
|
||
fdir, fn = os.path.split(fdir)
|
||
rem, _ = vsplit(rem)
|
||
|
||
bos.makedirs(fdir)
|
||
|
||
open_ka: dict[str, Any] = {"fun": open}
|
||
open_a = ["wb", self.args.iobuf]
|
||
|
||
# user-request || config-force
|
||
if ("gz" in vfs.flags or "xz" in vfs.flags) and (
|
||
"pk" in vfs.flags
|
||
or "pk" in self.uparam
|
||
or "gz" in self.uparam
|
||
or "xz" in self.uparam
|
||
):
|
||
fb = {"gz": 9, "xz": 0} # default/fallback level
|
||
lv = {} # selected level
|
||
alg = "" # selected algo (gz=preferred)
|
||
|
||
# user-prefs first
|
||
if "gz" in self.uparam or "pk" in self.uparam: # def.pk
|
||
alg = "gz"
|
||
if "xz" in self.uparam:
|
||
alg = "xz"
|
||
if alg:
|
||
zso = self.uparam.get(alg)
|
||
lv[alg] = fb[alg] if zso is None else int(zso)
|
||
|
||
if alg not in vfs.flags:
|
||
alg = "gz" if "gz" in vfs.flags else "xz"
|
||
|
||
# then server overrides
|
||
pk = vfs.flags.get("pk")
|
||
if pk is not None:
|
||
# config-forced on
|
||
alg = alg or "gz" # def.pk
|
||
try:
|
||
# config-forced opts
|
||
alg, nlv = pk.split(",")
|
||
lv[alg] = int(nlv)
|
||
except:
|
||
pass
|
||
|
||
lv[alg] = lv.get(alg) or fb.get(alg) or 0
|
||
|
||
self.log("compressing with {} level {}".format(alg, lv.get(alg)))
|
||
if alg == "gz":
|
||
open_ka["fun"] = gzip.GzipFile
|
||
open_a = ["wb", lv[alg], None, 0x5FEE6600] # 2021-01-01
|
||
elif alg == "xz":
|
||
assert lzma # type: ignore # !rm
|
||
open_ka = {"fun": lzma.open, "preset": lv[alg]}
|
||
open_a = ["wb"]
|
||
else:
|
||
self.log("fallthrough? thats a bug", 1)
|
||
|
||
suffix = "-{:.6f}-{}".format(time.time(), self.dip())
|
||
nameless = not fn
|
||
if nameless:
|
||
fn = vfs.flags["put_name2"].format(now=time.time(), cip=self.dip())
|
||
|
||
params = {"suffix": suffix, "fdir": fdir}
|
||
if self.args.nw:
|
||
params = {}
|
||
fn = os.devnull
|
||
|
||
params.update(open_ka)
|
||
assert fn # !rm
|
||
|
||
if not self.args.nw:
|
||
if rnd:
|
||
fn = rand_name(fdir, fn, rnd)
|
||
|
||
fn = sanitize_fn(fn or "", "")
|
||
|
||
path = os.path.join(fdir, fn)
|
||
|
||
if xbu:
|
||
at = time.time() - lifetime
|
||
vp = vjoin(self.vpath, fn) if nameless else self.vpath
|
||
hr = runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xbu.http.dump",
|
||
xbu,
|
||
path,
|
||
vp,
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(self.vpath, self.uname),
|
||
at,
|
||
remains,
|
||
self.ip,
|
||
at,
|
||
"",
|
||
)
|
||
if not hr:
|
||
t = "upload blocked by xbu server config"
|
||
self.log(t, 1)
|
||
raise Pebkac(403, t)
|
||
if hr.get("reloc"):
|
||
x = pathmod(self.asrv.vfs, path, vp, hr["reloc"])
|
||
if x:
|
||
if self.args.hook_v:
|
||
log_reloc(self.log, hr["reloc"], x, path, vp, fn, vfs, rem)
|
||
fdir, self.vpath, fn, (vfs, rem) = x
|
||
if self.args.nw:
|
||
fn = os.devnull
|
||
else:
|
||
bos.makedirs(fdir)
|
||
path = os.path.join(fdir, fn)
|
||
if not nameless:
|
||
self.vpath = vjoin(self.vpath, fn)
|
||
params["fdir"] = fdir
|
||
|
||
if is_put and not (self.args.no_dav or self.args.nw) and bos.path.exists(path):
|
||
# allow overwrite if...
|
||
# * volflag 'daw' is set, or client is definitely webdav
|
||
# * and account has delete-access
|
||
# or...
|
||
# * file exists, is empty, sufficiently new
|
||
# * and there is no .PARTIAL
|
||
|
||
tnam = fn + ".PARTIAL"
|
||
if self.args.dotpart:
|
||
tnam = "." + tnam
|
||
|
||
if (
|
||
self.can_delete
|
||
and (vfs.flags.get("daw") or "x-oc-mtime" in self.headers)
|
||
) or (
|
||
not bos.path.exists(os.path.join(fdir, tnam))
|
||
and not bos.path.getsize(path)
|
||
and bos.path.getmtime(path) >= time.time() - self.args.blank_wt
|
||
):
|
||
# small toctou, but better than clobbering a hardlink
|
||
wunlink(self.log, path, vfs.flags)
|
||
|
||
hasher = None
|
||
copier = hashcopy
|
||
halg = self.ouparam.get("ck") or self.headers.get("ck") or vfs.flags["put_ck"]
|
||
if halg == "sha512":
|
||
pass
|
||
elif halg == "no":
|
||
copier = justcopy
|
||
halg = ""
|
||
elif halg == "md5":
|
||
hasher = hashlib.md5(**USED4SEC)
|
||
elif halg == "sha1":
|
||
hasher = hashlib.sha1(**USED4SEC)
|
||
elif halg == "sha256":
|
||
hasher = hashlib.sha256(**USED4SEC)
|
||
elif halg in ("blake2", "b2"):
|
||
hasher = hashlib.blake2b(**USED4SEC)
|
||
elif halg in ("blake2s", "b2s"):
|
||
hasher = hashlib.blake2s(**USED4SEC)
|
||
else:
|
||
raise Pebkac(500, "unknown hash alg")
|
||
|
||
f, fn = ren_open(fn, *open_a, **params)
|
||
try:
|
||
path = os.path.join(fdir, fn)
|
||
post_sz, sha_hex, sha_b64 = copier(reader, f, hasher, 0, self.args.s_wr_slp)
|
||
finally:
|
||
f.close()
|
||
|
||
if lim:
|
||
lim.nup(self.ip)
|
||
lim.bup(self.ip, post_sz)
|
||
try:
|
||
lim.chk_sz(post_sz)
|
||
lim.chk_vsz(self.conn.hsrv.broker, vfs.realpath, post_sz)
|
||
except:
|
||
wunlink(self.log, path, vfs.flags)
|
||
raise
|
||
|
||
if self.args.nw:
|
||
return post_sz, halg, sha_hex, sha_b64, remains, path, ""
|
||
|
||
at = mt = time.time() - lifetime
|
||
cli_mt = self.headers.get("x-oc-mtime")
|
||
if cli_mt:
|
||
try:
|
||
mt = int(cli_mt)
|
||
times = (int(time.time()), mt)
|
||
bos.utime(path, times, False)
|
||
except:
|
||
pass
|
||
|
||
if nameless and "magic" in vfs.flags:
|
||
try:
|
||
ext = self.conn.hsrv.magician.ext(path)
|
||
except Exception as ex:
|
||
self.log("filetype detection failed for %r: %s" % (path, ex), 6)
|
||
ext = None
|
||
|
||
if ext:
|
||
if rnd:
|
||
fn2 = rand_name(fdir, "a." + ext, rnd)
|
||
else:
|
||
fn2 = fn.rsplit(".", 1)[0] + "." + ext
|
||
|
||
params["suffix"] = suffix[:-4]
|
||
f, fn2 = ren_open(fn2, *open_a, **params)
|
||
f.close()
|
||
|
||
path2 = os.path.join(fdir, fn2)
|
||
atomic_move(self.log, path, path2, vfs.flags)
|
||
fn = fn2
|
||
path = path2
|
||
|
||
if xau:
|
||
vp = vjoin(self.vpath, fn) if nameless else self.vpath
|
||
hr = runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xau.http.dump",
|
||
xau,
|
||
path,
|
||
vp,
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(self.vpath, self.uname),
|
||
mt,
|
||
post_sz,
|
||
self.ip,
|
||
at,
|
||
"",
|
||
)
|
||
if not hr:
|
||
t = "upload blocked by xau server config"
|
||
self.log(t, 1)
|
||
wunlink(self.log, path, vfs.flags)
|
||
raise Pebkac(403, t)
|
||
if hr.get("reloc"):
|
||
x = pathmod(self.asrv.vfs, path, vp, hr["reloc"])
|
||
if x:
|
||
if self.args.hook_v:
|
||
log_reloc(self.log, hr["reloc"], x, path, vp, fn, vfs, rem)
|
||
fdir, self.vpath, fn, (vfs, rem) = x
|
||
bos.makedirs(fdir)
|
||
path2 = os.path.join(fdir, fn)
|
||
atomic_move(self.log, path, path2, vfs.flags)
|
||
path = path2
|
||
if not nameless:
|
||
self.vpath = vjoin(self.vpath, fn)
|
||
sz = bos.path.getsize(path)
|
||
else:
|
||
sz = post_sz
|
||
|
||
vfs, rem = vfs.get_dbv(rem)
|
||
self.conn.hsrv.broker.say(
|
||
"up2k.hash_file",
|
||
vfs.realpath,
|
||
vfs.vpath,
|
||
vfs.flags,
|
||
rem,
|
||
fn,
|
||
self.ip,
|
||
at,
|
||
self.uname,
|
||
True,
|
||
)
|
||
|
||
vsuf = ""
|
||
if (self.can_read or self.can_upget) and "fk" in vfs.flags:
|
||
alg = 2 if "fka" in vfs.flags else 1
|
||
vsuf = "?k=" + self.gen_fk(
|
||
alg,
|
||
self.args.fk_salt,
|
||
path,
|
||
sz,
|
||
0 if ANYWIN else bos.stat(path).st_ino,
|
||
)[: vfs.flags["fk"]]
|
||
|
||
if "media" in self.uparam or "medialinks" in vfs.flags:
|
||
vsuf += "&v" if vsuf else "?v"
|
||
|
||
vpath = "/".join([x for x in [vfs.vpath, rem, fn] if x])
|
||
vpath = quotep(vpath)
|
||
|
||
url = "{}://{}/{}".format(
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
self.args.RS + vpath + vsuf,
|
||
)
|
||
|
||
return post_sz, halg, sha_hex, sha_b64, remains, path, url
|
||
|
||
def handle_stash(self, is_put: bool) -> bool:
|
||
post_sz, halg, sha_hex, sha_b64, remains, path, url = self.dump_to_file(is_put)
|
||
spd = self._spd(post_sz)
|
||
t = "%s wrote %d/%d bytes to %r # %s"
|
||
self.log(t % (spd, post_sz, remains, path, sha_b64[:28])) # 21
|
||
|
||
mime = "text/plain; charset=utf-8"
|
||
ac = self.uparam.get("want") or self.headers.get("accept") or ""
|
||
if ac:
|
||
ac = ac.split(";", 1)[0].lower()
|
||
if ac == "application/json":
|
||
ac = "json"
|
||
if ac == "url":
|
||
t = url
|
||
elif ac == "json" or "j" in self.uparam:
|
||
jmsg = {"fileurl": url, "filesz": post_sz}
|
||
if halg:
|
||
jmsg[halg] = sha_hex[:56]
|
||
jmsg["sha_b64"] = sha_b64
|
||
|
||
mime = "application/json"
|
||
t = json.dumps(jmsg, indent=2, sort_keys=True)
|
||
else:
|
||
t = "{}\n{}\n{}\n{}\n".format(post_sz, sha_b64, sha_hex[:56], url)
|
||
|
||
h = {"Location": url} if is_put and url else {}
|
||
|
||
if "x-oc-mtime" in self.headers:
|
||
h["X-OC-MTime"] = "accepted"
|
||
t = "" # some webdav clients expect/prefer this
|
||
|
||
self.reply(t.encode("utf-8", "replace"), 201, mime=mime, headers=h)
|
||
return True
|
||
|
||
def bakflip(
|
||
self,
|
||
f: typing.BinaryIO,
|
||
ap: str,
|
||
ofs: int,
|
||
sz: int,
|
||
good_sha: str,
|
||
bad_sha: str,
|
||
flags: dict[str, Any],
|
||
) -> None:
|
||
now = time.time()
|
||
t = "bad-chunk: %.3f %s %s %d %s %s %r"
|
||
t = t % (now, bad_sha, good_sha, ofs, self.ip, self.uname, ap)
|
||
self.log(t, 5)
|
||
|
||
if self.args.bf_log:
|
||
try:
|
||
with open(self.args.bf_log, "ab+") as f2:
|
||
f2.write((t + "\n").encode("utf-8", "replace"))
|
||
except Exception as ex:
|
||
self.log("append %s failed: %r" % (self.args.bf_log, ex))
|
||
|
||
if not self.args.bak_flips or self.args.nw:
|
||
return
|
||
|
||
sdir = self.args.bf_dir
|
||
fp = os.path.join(sdir, bad_sha)
|
||
if bos.path.exists(fp):
|
||
return self.log("no bakflip; have it", 6)
|
||
|
||
if not bos.path.isdir(sdir):
|
||
bos.makedirs(sdir)
|
||
|
||
if len(bos.listdir(sdir)) >= self.args.bf_nc:
|
||
return self.log("no bakflip; too many", 3)
|
||
|
||
nrem = sz
|
||
f.seek(ofs)
|
||
with open(fp, "wb") as fo:
|
||
while nrem:
|
||
buf = f.read(min(nrem, self.args.iobuf))
|
||
if not buf:
|
||
break
|
||
|
||
nrem -= len(buf)
|
||
fo.write(buf)
|
||
|
||
if nrem:
|
||
self.log("bakflip truncated; {} remains".format(nrem), 1)
|
||
atomic_move(self.log, fp, fp + ".trunc", flags)
|
||
else:
|
||
self.log("bakflip ok", 2)
|
||
|
||
def _spd(self, nbytes: int, add: bool = True) -> str:
|
||
if add:
|
||
self.conn.nbyte += nbytes
|
||
|
||
spd1 = get_spd(nbytes, self.t0)
|
||
spd2 = get_spd(self.conn.nbyte, self.conn.t0)
|
||
return "%s %s n%s" % (spd1, spd2, self.conn.nreq)
|
||
|
||
def handle_post_multipart(self) -> bool:
|
||
self.parser = MultipartParser(self.log, self.args, self.sr, self.headers)
|
||
self.parser.parse()
|
||
|
||
file0: list[tuple[str, Optional[str], Generator[bytes, None, None]]] = []
|
||
try:
|
||
act = self.parser.require("act", 64)
|
||
except WrongPostKey as ex:
|
||
if ex.got == "f" and ex.fname:
|
||
self.log("missing 'act', but looks like an upload so assuming that")
|
||
file0 = [(ex.got, ex.fname, ex.datagen)]
|
||
act = "bput"
|
||
else:
|
||
raise
|
||
|
||
if act == "login":
|
||
return self.handle_login()
|
||
|
||
if act == "mkdir":
|
||
return self.handle_mkdir()
|
||
|
||
if act == "new_md":
|
||
# kinda silly but has the least side effects
|
||
return self.handle_new_md()
|
||
|
||
if act in ("bput", "uput"):
|
||
return self.handle_plain_upload(file0, act == "uput")
|
||
|
||
if act == "tput":
|
||
return self.handle_text_upload()
|
||
|
||
if act == "zip":
|
||
return self.handle_zip_post()
|
||
|
||
if act == "chpw":
|
||
return self.handle_chpw()
|
||
|
||
if act == "logout":
|
||
return self.handle_logout()
|
||
|
||
raise Pebkac(422, 'invalid action "{}"'.format(act))
|
||
|
||
def handle_zip_post(self) -> bool:
|
||
assert self.parser # !rm
|
||
try:
|
||
k = next(x for x in self.uparam if x in ("zip", "tar"))
|
||
except:
|
||
raise Pebkac(422, "need zip or tar keyword")
|
||
|
||
v = self.uparam[k]
|
||
|
||
if self._use_dirkey(self.vn, ""):
|
||
vn = self.vn
|
||
rem = self.rem
|
||
else:
|
||
vn, rem = self.asrv.vfs.get(self.vpath, self.uname, True, False)
|
||
|
||
zs = self.parser.require("files", 1024 * 1024)
|
||
if not zs:
|
||
raise Pebkac(422, "need files list")
|
||
|
||
items = zs.replace("\r", "").split("\n")
|
||
items = [unquotep(x) for x in items if items]
|
||
|
||
self.parser.drop()
|
||
return self.tx_zip(k, v, "", vn, rem, items)
|
||
|
||
def handle_post_json(self) -> bool:
|
||
try:
|
||
remains = int(self.headers["content-length"])
|
||
except:
|
||
raise Pebkac(411)
|
||
|
||
if remains > 1024 * 1024:
|
||
raise Pebkac(413, "json 2big")
|
||
|
||
enc = "utf-8"
|
||
ctype = self.headers.get("content-type", "").lower()
|
||
if "charset" in ctype:
|
||
enc = ctype.split("charset")[1].strip(" =").split(";")[0].strip()
|
||
|
||
try:
|
||
json_buf = self.sr.recv_ex(remains)
|
||
except UnrecvEOF:
|
||
raise Pebkac(422, "client disconnected while posting JSON")
|
||
|
||
try:
|
||
body = json.loads(json_buf.decode(enc, "replace"))
|
||
try:
|
||
zds = {k: v for k, v in body.items()}
|
||
zds["hash"] = "%d chunks" % (len(body["hash"]),)
|
||
except:
|
||
zds = body
|
||
t = "POST len=%d type=%s ip=%s user=%s req=%r json=%s"
|
||
self.log(t % (len(json_buf), enc, self.ip, self.uname, self.req, zds))
|
||
except:
|
||
raise Pebkac(422, "you POSTed %d bytes of invalid json" % (len(json_buf),))
|
||
|
||
# self.reply(b"cloudflare", 503)
|
||
# return True
|
||
|
||
if "srch" in self.uparam or "srch" in body:
|
||
return self.handle_search(body)
|
||
|
||
if "share" in self.uparam:
|
||
return self.handle_share(body)
|
||
|
||
if "delete" in self.uparam:
|
||
return self.handle_rm(body)
|
||
|
||
name = undot(body["name"])
|
||
if "/" in name:
|
||
raise Pebkac(400, "your client is old; press CTRL-SHIFT-R and try again")
|
||
|
||
vfs, rem = self.asrv.vfs.get(self.vpath, self.uname, False, True)
|
||
dbv, vrem = vfs.get_dbv(rem)
|
||
|
||
name = sanitize_fn(name, "")
|
||
if (
|
||
not self.can_read
|
||
and self.can_write
|
||
and name.lower() in FN_EMB
|
||
and "wo_up_readme" not in dbv.flags
|
||
):
|
||
name = "_wo_" + name
|
||
|
||
body["name"] = name
|
||
body["vtop"] = dbv.vpath
|
||
body["ptop"] = dbv.realpath
|
||
body["prel"] = vrem
|
||
body["host"] = self.host
|
||
body["user"] = self.uname
|
||
body["addr"] = self.ip
|
||
body["vcfg"] = dbv.flags
|
||
|
||
if not self.can_delete:
|
||
body.pop("replace", None)
|
||
|
||
if rem:
|
||
dst = vfs.canonical(rem)
|
||
try:
|
||
if not bos.path.isdir(dst):
|
||
bos.makedirs(dst)
|
||
except OSError as ex:
|
||
self.log("makedirs failed %r" % (dst,))
|
||
if not bos.path.isdir(dst):
|
||
if ex.errno == errno.EACCES:
|
||
raise Pebkac(500, "the server OS denied write-access")
|
||
|
||
if ex.errno == errno.EEXIST:
|
||
raise Pebkac(400, "some file got your folder name")
|
||
|
||
raise Pebkac(500, min_ex())
|
||
except:
|
||
raise Pebkac(500, min_ex())
|
||
|
||
# not to protect u2fh, but to prevent handshakes while files are closing
|
||
with self.u2mutex:
|
||
x = self.conn.hsrv.broker.ask("up2k.handle_json", body, self.u2fh.aps)
|
||
ret = x.get()
|
||
|
||
if self.args.shr and self.vpath.startswith(self.args.shr1):
|
||
# strip common suffix (uploader's folder structure)
|
||
vp_req, vp_vfs = vroots(self.vpath, vjoin(dbv.vpath, vrem))
|
||
if not ret["purl"].startswith(vp_vfs):
|
||
t = "share-mapping failed; req=%r dbv=%r vrem=%r n1=%r n2=%r purl=%r"
|
||
zt = (self.vpath, dbv.vpath, vrem, vp_req, vp_vfs, ret["purl"])
|
||
raise Pebkac(500, t % zt)
|
||
ret["purl"] = vp_req + ret["purl"][len(vp_vfs) :]
|
||
|
||
if self.is_vproxied:
|
||
if "purl" in ret:
|
||
ret["purl"] = self.args.SR + ret["purl"]
|
||
|
||
ret = json.dumps(ret)
|
||
self.log(ret)
|
||
self.reply(ret.encode("utf-8"), mime="application/json")
|
||
return True
|
||
|
||
def handle_search(self, body: dict[str, Any]) -> bool:
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; search is disabled")
|
||
raise Pebkac(500, "server busy, cannot search; please retry in a bit")
|
||
|
||
vols: list[VFS] = []
|
||
seen: dict[VFS, bool] = {}
|
||
for vtop in self.rvol:
|
||
vfs, _ = self.asrv.vfs.get(vtop, self.uname, True, False)
|
||
vfs = vfs.dbv or vfs
|
||
if vfs in seen:
|
||
continue
|
||
|
||
seen[vfs] = True
|
||
vols.append(vfs)
|
||
|
||
t0 = time.time()
|
||
if idx.p_end:
|
||
penalty = 0.7
|
||
t_idle = t0 - idx.p_end
|
||
if idx.p_dur > 0.7 and t_idle < penalty:
|
||
t = "rate-limit {:.1f} sec, cost {:.2f}, idle {:.2f}"
|
||
raise Pebkac(429, t.format(penalty, idx.p_dur, t_idle))
|
||
|
||
if "srch" in body:
|
||
# search by up2k hashlist
|
||
vbody = copy.deepcopy(body)
|
||
vbody["hash"] = len(vbody["hash"])
|
||
self.log("qj: " + repr(vbody))
|
||
hits = idx.fsearch(self.uname, vols, body)
|
||
msg: Any = repr(hits)
|
||
taglist: list[str] = []
|
||
trunc = False
|
||
else:
|
||
# search by query params
|
||
q = body["q"]
|
||
n = body.get("n", self.args.srch_hits)
|
||
self.log("qj: %r |%d|" % (q, n))
|
||
hits, taglist, trunc = idx.search(self.uname, vols, q, n)
|
||
msg = len(hits)
|
||
|
||
idx.p_end = time.time()
|
||
idx.p_dur = idx.p_end - t0
|
||
self.log("q#: %r (%.2fs)" % (msg, idx.p_dur))
|
||
|
||
order = []
|
||
for t in self.args.mte:
|
||
if t in taglist:
|
||
order.append(t)
|
||
for t in taglist:
|
||
if t not in order:
|
||
order.append(t)
|
||
|
||
if self.is_vproxied:
|
||
for hit in hits:
|
||
hit["rp"] = self.args.RS + hit["rp"]
|
||
|
||
rj = {"hits": hits, "tag_order": order, "trunc": trunc}
|
||
r = json.dumps(rj).encode("utf-8")
|
||
self.reply(r, mime="application/json")
|
||
return True
|
||
|
||
def handle_post_binary(self) -> bool:
|
||
try:
|
||
postsize = remains = int(self.headers["content-length"])
|
||
except:
|
||
raise Pebkac(400, "you must supply a content-length for binary POST")
|
||
|
||
try:
|
||
chashes = self.headers["x-up2k-hash"].split(",")
|
||
wark = self.headers["x-up2k-wark"]
|
||
except KeyError:
|
||
raise Pebkac(400, "need hash and wark headers for binary POST")
|
||
|
||
chashes = [x.strip() for x in chashes]
|
||
if len(chashes) == 3 and len(chashes[1]) == 1:
|
||
# the first hash, then length of consecutive hashes,
|
||
# then a list of stitched hashes as one long string
|
||
clen = int(chashes[1])
|
||
siblings = chashes[2]
|
||
chashes = [chashes[0]]
|
||
for n in range(0, len(siblings), clen):
|
||
chashes.append(siblings[n : n + clen])
|
||
|
||
vfs, _ = self.asrv.vfs.get(self.vpath, self.uname, False, True)
|
||
ptop = vfs.get_dbv("")[0].realpath
|
||
# if this is a share, then get_dbv has been overridden to return
|
||
# the dbv (which does not exist as a property). And its realpath
|
||
# could point into the middle of its origin vfs node, meaning it
|
||
# is not necessarily registered with up2k, so get_dbv is crucial
|
||
|
||
broker = self.conn.hsrv.broker
|
||
x = broker.ask("up2k.handle_chunks", ptop, wark, chashes)
|
||
response = x.get()
|
||
chashes, chunksize, cstarts, path, lastmod, fsize, sprs = response
|
||
maxsize = chunksize * len(chashes)
|
||
cstart0 = cstarts[0]
|
||
locked = chashes # remaining chunks to be received in this request
|
||
written = [] # chunks written to disk, but not yet released by up2k
|
||
num_left = -1 # num chunks left according to most recent up2k release
|
||
bail1 = False # used in sad path to avoid contradicting error-text
|
||
treport = time.time() # ratelimit up2k reporting to reduce overhead
|
||
|
||
if "x-up2k-subc" in self.headers:
|
||
sc_ofs = int(self.headers["x-up2k-subc"])
|
||
chash = chashes[0]
|
||
|
||
u2sc = self.conn.hsrv.u2sc
|
||
try:
|
||
sc_pofs, hasher = u2sc[chash]
|
||
if not sc_ofs:
|
||
t = "client restarted the chunk; forgetting subchunk offset %d"
|
||
self.log(t % (sc_pofs,))
|
||
raise Exception()
|
||
except:
|
||
sc_pofs = 0
|
||
hasher = hashlib.sha512()
|
||
|
||
et = "subchunk protocol error; resetting chunk "
|
||
if sc_pofs != sc_ofs:
|
||
u2sc.pop(chash, None)
|
||
t = "%s[%s]: the expected resume-point was %d, not %d"
|
||
raise Pebkac(400, t % (et, chash, sc_pofs, sc_ofs))
|
||
if len(cstarts) > 1:
|
||
u2sc.pop(chash, None)
|
||
t = "%s[%s]: only a single subchunk can be uploaded in one request; you are sending %d chunks"
|
||
raise Pebkac(400, t % (et, chash, len(cstarts)))
|
||
csize = min(chunksize, fsize - cstart0[0])
|
||
cstart0[0] += sc_ofs # also sets cstarts[0][0]
|
||
sc_next_ofs = sc_ofs + postsize
|
||
if sc_next_ofs > csize:
|
||
u2sc.pop(chash, None)
|
||
t = "%s[%s]: subchunk offset (%d) plus postsize (%d) exceeds chunksize (%d)"
|
||
raise Pebkac(400, t % (et, chash, sc_ofs, postsize, csize))
|
||
else:
|
||
final_subchunk = sc_next_ofs == csize
|
||
t = "subchunk %s %d:%d/%d %s"
|
||
zs = "END" if final_subchunk else ""
|
||
self.log(t % (chash[:15], sc_ofs, sc_next_ofs, csize, zs), 6)
|
||
if final_subchunk:
|
||
u2sc.pop(chash, None)
|
||
else:
|
||
u2sc[chash] = (sc_next_ofs, hasher)
|
||
else:
|
||
hasher = None
|
||
final_subchunk = True
|
||
|
||
try:
|
||
if self.args.nw:
|
||
path = os.devnull
|
||
|
||
if remains > maxsize:
|
||
t = "your client is sending %d bytes which is too much (server expected %d bytes at most)"
|
||
raise Pebkac(400, t % (remains, maxsize))
|
||
|
||
t = "writing %r %s+%d #%d+%d %s"
|
||
chunkno = cstart0[0] // chunksize
|
||
zs = " ".join([chashes[0][:15]] + [x[:9] for x in chashes[1:]])
|
||
self.log(t % (path, cstart0, remains, chunkno, len(chashes), zs))
|
||
|
||
f = None
|
||
fpool = not self.args.no_fpool and sprs
|
||
if fpool:
|
||
with self.u2mutex:
|
||
try:
|
||
f = self.u2fh.pop(path)
|
||
except:
|
||
pass
|
||
|
||
f = f or open(fsenc(path), "rb+", self.args.iobuf)
|
||
|
||
try:
|
||
for chash, cstart in zip(chashes, cstarts):
|
||
f.seek(cstart[0])
|
||
reader = read_socket(
|
||
self.sr, self.args.s_rd_sz, min(remains, chunksize)
|
||
)
|
||
post_sz, _, sha_b64 = hashcopy(
|
||
reader, f, hasher, 0, self.args.s_wr_slp
|
||
)
|
||
|
||
if sha_b64 != chash and final_subchunk:
|
||
try:
|
||
self.bakflip(
|
||
f, path, cstart[0], post_sz, chash, sha_b64, vfs.flags
|
||
)
|
||
except:
|
||
self.log("bakflip failed: " + min_ex())
|
||
|
||
t = "your chunk got corrupted somehow (received {} bytes); expected vs received hash:\n{}\n{}"
|
||
raise Pebkac(400, t.format(post_sz, chash, sha_b64))
|
||
|
||
remains -= chunksize
|
||
|
||
if len(cstart) > 1 and path != os.devnull:
|
||
t = " & ".join(unicode(x) for x in cstart[1:])
|
||
self.log("clone %s to %s" % (cstart[0], t))
|
||
ofs = 0
|
||
while ofs < chunksize:
|
||
bufsz = max(4 * 1024 * 1024, self.args.iobuf)
|
||
bufsz = min(chunksize - ofs, bufsz)
|
||
f.seek(cstart[0] + ofs)
|
||
buf = f.read(bufsz)
|
||
for wofs in cstart[1:]:
|
||
f.seek(wofs + ofs)
|
||
f.write(buf)
|
||
|
||
ofs += len(buf)
|
||
|
||
self.log("clone {} done".format(cstart[0]))
|
||
|
||
# be quick to keep the tcp winsize scale;
|
||
# if we can't confirm rn then that's fine
|
||
if final_subchunk:
|
||
written.append(chash)
|
||
now = time.time()
|
||
if now - treport < 1:
|
||
continue
|
||
treport = now
|
||
x = broker.ask("up2k.fast_confirm_chunks", ptop, wark, written)
|
||
num_left, t = x.get()
|
||
if num_left < -1:
|
||
self.loud_reply(t, status=500)
|
||
locked = written = []
|
||
return False
|
||
elif num_left >= 0:
|
||
t = "got %d more chunks, %d left"
|
||
self.log(t % (len(written), num_left), 6)
|
||
locked = locked[len(written) :]
|
||
written = []
|
||
|
||
if not fpool:
|
||
f.close()
|
||
else:
|
||
with self.u2mutex:
|
||
self.u2fh.put(path, f)
|
||
except:
|
||
# maybe busted handle (eg. disk went full)
|
||
f.close()
|
||
raise
|
||
finally:
|
||
if locked:
|
||
# now block until all chunks released+confirmed
|
||
x = broker.ask("up2k.confirm_chunks", ptop, wark, written, locked)
|
||
num_left, t = x.get()
|
||
if num_left < 0:
|
||
self.loud_reply(t, status=500)
|
||
bail1 = True
|
||
else:
|
||
t = "got %d more chunks, %d left"
|
||
self.log(t % (len(written), num_left), 6)
|
||
|
||
if num_left < 0:
|
||
if bail1:
|
||
return False
|
||
raise Pebkac(500, "unconfirmed; see serverlog")
|
||
|
||
if not num_left and fpool:
|
||
with self.u2mutex:
|
||
self.u2fh.close(path)
|
||
|
||
if not num_left and not self.args.nw:
|
||
broker.ask("up2k.finish_upload", ptop, wark, self.u2fh.aps).get()
|
||
|
||
cinf = self.headers.get("x-up2k-stat", "")
|
||
|
||
spd = self._spd(postsize)
|
||
self.log("%70s thank %r" % (spd, cinf))
|
||
self.reply(b"thank")
|
||
return True
|
||
|
||
def handle_chpw(self) -> bool:
|
||
assert self.parser # !rm
|
||
pwd = self.parser.require("pw", 64)
|
||
self.parser.drop()
|
||
|
||
ok, msg = self.asrv.chpw(self.conn.hsrv.broker, self.uname, pwd)
|
||
if ok:
|
||
ok, msg = self.get_pwd_cookie(pwd)
|
||
if ok:
|
||
msg = "new password OK"
|
||
|
||
redir = (self.args.SRS + "?h") if ok else ""
|
||
h2 = '<a href="' + self.args.SRS + '?h">ack</a>'
|
||
html = self.j2s("msg", h1=msg, h2=h2, redir=redir)
|
||
self.reply(html.encode("utf-8"))
|
||
return True
|
||
|
||
def handle_login(self) -> bool:
|
||
assert self.parser # !rm
|
||
pwd = self.parser.require("cppwd", 64)
|
||
try:
|
||
uhash = self.parser.require("uhash", 256)
|
||
except:
|
||
uhash = ""
|
||
self.parser.drop()
|
||
|
||
if not pwd:
|
||
raise Pebkac(422, "password cannot be blank")
|
||
|
||
dst = self.args.SRS
|
||
if self.vpath:
|
||
dst += quotep(self.vpaths)
|
||
|
||
dst += self.ourlq()
|
||
|
||
uhash = uhash.lstrip("#")
|
||
if uhash not in ("", "-"):
|
||
dst += "&" if "?" in dst else "?"
|
||
dst += "_=1#" + html_escape(uhash, True, True)
|
||
|
||
_, msg = self.get_pwd_cookie(pwd)
|
||
html = self.j2s("msg", h1=msg, h2='<a href="' + dst + '">ack</a>', redir=dst)
|
||
self.reply(html.encode("utf-8"))
|
||
return True
|
||
|
||
def handle_logout(self) -> bool:
|
||
assert self.parser # !rm
|
||
self.parser.drop()
|
||
|
||
self.log("logout " + self.uname)
|
||
if not self.uname.startswith("s_"):
|
||
self.asrv.forget_session(self.conn.hsrv.broker, self.uname)
|
||
self.get_pwd_cookie("x")
|
||
|
||
dst = self.args.SRS + "?h"
|
||
h2 = '<a href="' + dst + '">ack</a>'
|
||
html = self.j2s("msg", h1="ok bye", h2=h2, redir=dst)
|
||
self.reply(html.encode("utf-8"))
|
||
return True
|
||
|
||
def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
|
||
uname = self.asrv.sesa.get(pwd)
|
||
if not uname:
|
||
hpwd = self.asrv.ah.hash(pwd)
|
||
uname = self.asrv.iacct.get(hpwd)
|
||
if uname:
|
||
pwd = self.asrv.ases.get(uname) or pwd
|
||
if uname:
|
||
msg = "hi " + uname
|
||
dur = int(60 * 60 * self.args.logout)
|
||
else:
|
||
logpwd = pwd
|
||
if self.args.log_badpwd == 0:
|
||
logpwd = ""
|
||
elif self.args.log_badpwd == 2:
|
||
zb = hashlib.sha512(pwd.encode("utf-8", "replace")).digest()
|
||
logpwd = "%" + ub64enc(zb[:12]).decode("ascii")
|
||
|
||
if pwd != "x":
|
||
self.log("invalid password: %r" % (logpwd,), 3)
|
||
self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords")
|
||
|
||
msg = "naw dude"
|
||
pwd = "x" # nosec
|
||
dur = 0
|
||
|
||
if pwd == "x":
|
||
# reset both plaintext and tls
|
||
# (only affects active tls cookies when tls)
|
||
for k in ("cppwd", "cppws") if self.is_https else ("cppwd",):
|
||
ck = gencookie(k, pwd, self.args.R, False)
|
||
self.out_headerlist.append(("Set-Cookie", ck))
|
||
self.out_headers.pop("Set-Cookie", None) # drop keepalive
|
||
else:
|
||
k = "cppws" if self.is_https else "cppwd"
|
||
ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly")
|
||
self.out_headers["Set-Cookie"] = ck
|
||
|
||
return dur > 0, msg
|
||
|
||
def handle_mkdir(self) -> bool:
|
||
assert self.parser # !rm
|
||
new_dir = self.parser.require("name", 512)
|
||
self.parser.drop()
|
||
|
||
return self._mkdir(vjoin(self.vpath, new_dir))
|
||
|
||
def _mkdir(self, vpath: str, dav: bool = False) -> bool:
|
||
nullwrite = self.args.nw
|
||
self.gctx = vpath
|
||
vpath = undot(vpath)
|
||
vfs, rem = self.asrv.vfs.get(vpath, self.uname, False, True)
|
||
rem = sanitize_vpath(rem, "/")
|
||
fn = vfs.canonical(rem)
|
||
|
||
if not nullwrite:
|
||
fdir = os.path.dirname(fn)
|
||
|
||
if dav and not bos.path.isdir(fdir):
|
||
raise Pebkac(409, "parent folder does not exist")
|
||
|
||
if bos.path.isdir(fn):
|
||
raise Pebkac(405, 'folder "/%s" already exists' % (vpath,))
|
||
|
||
try:
|
||
bos.makedirs(fn)
|
||
except OSError as ex:
|
||
if ex.errno == errno.EACCES:
|
||
raise Pebkac(500, "the server OS denied write-access")
|
||
|
||
raise Pebkac(500, "mkdir failed:\n" + min_ex())
|
||
except:
|
||
raise Pebkac(500, min_ex())
|
||
|
||
self.out_headers["X-New-Dir"] = quotep(self.args.RS + vpath)
|
||
|
||
if dav:
|
||
self.reply(b"", 201)
|
||
else:
|
||
self.redirect(vpath, status=201)
|
||
|
||
return True
|
||
|
||
def handle_new_md(self) -> bool:
|
||
assert self.parser # !rm
|
||
new_file = self.parser.require("name", 512)
|
||
self.parser.drop()
|
||
|
||
nullwrite = self.args.nw
|
||
vfs, rem = self.asrv.vfs.get(self.vpath, self.uname, False, True)
|
||
self._assert_safe_rem(rem)
|
||
|
||
ext = "" if "." not in new_file else new_file.split(".")[-1]
|
||
if not ext or len(ext) > 5 or not self.can_delete:
|
||
new_file += ".md"
|
||
|
||
sanitized = sanitize_fn(new_file, "")
|
||
|
||
if not nullwrite:
|
||
fdir = vfs.canonical(rem)
|
||
fn = os.path.join(fdir, sanitized)
|
||
|
||
if bos.path.exists(fn):
|
||
raise Pebkac(500, "that file exists already")
|
||
|
||
with open(fsenc(fn), "wb") as f:
|
||
f.write(b"`GRUNNUR`\n")
|
||
|
||
vpath = "{}/{}".format(self.vpath, sanitized).lstrip("/")
|
||
self.redirect(vpath, "?edit")
|
||
return True
|
||
|
||
def upload_flags(self, vfs: VFS) -> tuple[int, int, list[str], list[str]]:
|
||
if self.args.nw:
|
||
rnd = 0
|
||
else:
|
||
rnd = int(self.uparam.get("rand") or self.headers.get("rand") or 0)
|
||
if vfs.flags.get("rand"): # force-enable
|
||
rnd = max(rnd, vfs.flags["nrand"])
|
||
|
||
zs = self.uparam.get("life", self.headers.get("life", ""))
|
||
if zs:
|
||
vlife = vfs.flags.get("lifetime") or 0
|
||
lifetime = max(0, int(vlife - int(zs)))
|
||
else:
|
||
lifetime = 0
|
||
|
||
return (
|
||
rnd,
|
||
lifetime,
|
||
vfs.flags.get("xbu") or [],
|
||
vfs.flags.get("xau") or [],
|
||
)
|
||
|
||
def handle_plain_upload(
|
||
self,
|
||
file0: list[tuple[str, Optional[str], Generator[bytes, None, None]]],
|
||
nohash: bool,
|
||
) -> bool:
|
||
assert self.parser
|
||
nullwrite = self.args.nw
|
||
vfs, rem = self.asrv.vfs.get(self.vpath, self.uname, False, True)
|
||
self._assert_safe_rem(rem)
|
||
|
||
hasher = None
|
||
if nohash:
|
||
halg = ""
|
||
copier = justcopy
|
||
else:
|
||
copier = hashcopy
|
||
halg = (
|
||
self.ouparam.get("ck") or self.headers.get("ck") or vfs.flags["bup_ck"]
|
||
)
|
||
if halg == "sha512":
|
||
pass
|
||
elif halg == "no":
|
||
copier = justcopy
|
||
halg = ""
|
||
elif halg == "md5":
|
||
hasher = hashlib.md5(**USED4SEC)
|
||
elif halg == "sha1":
|
||
hasher = hashlib.sha1(**USED4SEC)
|
||
elif halg == "sha256":
|
||
hasher = hashlib.sha256(**USED4SEC)
|
||
elif halg in ("blake2", "b2"):
|
||
hasher = hashlib.blake2b(**USED4SEC)
|
||
elif halg in ("blake2s", "b2s"):
|
||
hasher = hashlib.blake2s(**USED4SEC)
|
||
else:
|
||
raise Pebkac(500, "unknown hash alg")
|
||
|
||
upload_vpath = self.vpath
|
||
lim = vfs.get_dbv(rem)[0].lim
|
||
fdir_base = vfs.canonical(rem)
|
||
if lim:
|
||
fdir_base, rem = lim.all(
|
||
self.ip, rem, -1, vfs.realpath, fdir_base, self.conn.hsrv.broker
|
||
)
|
||
upload_vpath = "{}/{}".format(vfs.vpath, rem).strip("/")
|
||
if not nullwrite:
|
||
bos.makedirs(fdir_base)
|
||
|
||
rnd, lifetime, xbu, xau = self.upload_flags(vfs)
|
||
zs = self.uparam.get("want") or self.headers.get("accept") or ""
|
||
if zs:
|
||
zs = zs.split(";", 1)[0].lower()
|
||
if zs == "application/json":
|
||
zs = "json"
|
||
want_url = zs == "url"
|
||
want_json = zs == "json" or "j" in self.uparam
|
||
|
||
files: list[tuple[int, str, str, str, str, str]] = []
|
||
# sz, sha_hex, sha_b64, p_file, fname, abspath
|
||
errmsg = ""
|
||
tabspath = ""
|
||
dip = self.dip()
|
||
t0 = time.time()
|
||
try:
|
||
assert self.parser.gen
|
||
gens = itertools.chain(file0, self.parser.gen)
|
||
for nfile, (p_field, p_file, p_data) in enumerate(gens):
|
||
if not p_file:
|
||
self.log("discarding incoming file without filename")
|
||
# fallthrough
|
||
|
||
fdir = fdir_base
|
||
fname = sanitize_fn(p_file or "", "")
|
||
abspath = os.path.join(fdir, fname)
|
||
suffix = "-%.6f-%s" % (time.time(), dip)
|
||
if p_file and not nullwrite:
|
||
if rnd:
|
||
fname = rand_name(fdir, fname, rnd)
|
||
|
||
open_args = {"fdir": fdir, "suffix": suffix}
|
||
|
||
if "replace" in self.uparam:
|
||
if not self.can_delete:
|
||
self.log("user not allowed to overwrite with ?replace")
|
||
elif bos.path.exists(abspath):
|
||
try:
|
||
wunlink(self.log, abspath, vfs.flags)
|
||
t = "overwriting file with new upload: %r"
|
||
except:
|
||
t = "toctou while deleting for ?replace: %r"
|
||
self.log(t % (abspath,))
|
||
else:
|
||
open_args = {}
|
||
tnam = fname = os.devnull
|
||
fdir = abspath = ""
|
||
|
||
if xbu:
|
||
at = time.time() - lifetime
|
||
hr = runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xbu.http.bup",
|
||
xbu,
|
||
abspath,
|
||
vjoin(upload_vpath, fname),
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(upload_vpath, self.uname),
|
||
at,
|
||
0,
|
||
self.ip,
|
||
at,
|
||
"",
|
||
)
|
||
if not hr:
|
||
t = "upload blocked by xbu server config"
|
||
self.log(t, 1)
|
||
raise Pebkac(403, t)
|
||
if hr.get("reloc"):
|
||
zs = vjoin(upload_vpath, fname)
|
||
x = pathmod(self.asrv.vfs, abspath, zs, hr["reloc"])
|
||
if x:
|
||
if self.args.hook_v:
|
||
log_reloc(
|
||
self.log,
|
||
hr["reloc"],
|
||
x,
|
||
abspath,
|
||
zs,
|
||
fname,
|
||
vfs,
|
||
rem,
|
||
)
|
||
fdir, upload_vpath, fname, (vfs, rem) = x
|
||
abspath = os.path.join(fdir, fname)
|
||
if nullwrite:
|
||
fdir = abspath = ""
|
||
else:
|
||
open_args["fdir"] = fdir
|
||
|
||
if p_file and not nullwrite:
|
||
bos.makedirs(fdir)
|
||
|
||
# reserve destination filename
|
||
f, fname = ren_open(fname, "wb", fdir=fdir, suffix=suffix)
|
||
f.close()
|
||
|
||
tnam = fname + ".PARTIAL"
|
||
if self.args.dotpart:
|
||
tnam = "." + tnam
|
||
|
||
abspath = os.path.join(fdir, fname)
|
||
else:
|
||
open_args = {}
|
||
tnam = fname = os.devnull
|
||
fdir = abspath = ""
|
||
|
||
if lim:
|
||
lim.chk_bup(self.ip)
|
||
lim.chk_nup(self.ip)
|
||
|
||
try:
|
||
max_sz = 0
|
||
if lim:
|
||
v1 = lim.smax
|
||
v2 = lim.dfv - lim.dfl
|
||
max_sz = min(v1, v2) if v1 and v2 else v1 or v2
|
||
|
||
f, tnam = ren_open(tnam, "wb", self.args.iobuf, **open_args)
|
||
try:
|
||
tabspath = os.path.join(fdir, tnam)
|
||
self.log("writing to %r" % (tabspath,))
|
||
sz, sha_hex, sha_b64 = copier(
|
||
p_data, f, hasher, max_sz, self.args.s_wr_slp
|
||
)
|
||
if sz == 0:
|
||
raise Pebkac(400, "empty files in post")
|
||
finally:
|
||
f.close()
|
||
|
||
if lim:
|
||
lim.nup(self.ip)
|
||
lim.bup(self.ip, sz)
|
||
try:
|
||
lim.chk_df(tabspath, sz, True)
|
||
lim.chk_sz(sz)
|
||
lim.chk_vsz(self.conn.hsrv.broker, vfs.realpath, sz)
|
||
lim.chk_bup(self.ip)
|
||
lim.chk_nup(self.ip)
|
||
except:
|
||
if not nullwrite:
|
||
wunlink(self.log, tabspath, vfs.flags)
|
||
wunlink(self.log, abspath, vfs.flags)
|
||
fname = os.devnull
|
||
raise
|
||
|
||
if not nullwrite:
|
||
atomic_move(self.log, tabspath, abspath, vfs.flags)
|
||
|
||
tabspath = ""
|
||
|
||
at = time.time() - lifetime
|
||
if xau:
|
||
hr = runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xau.http.bup",
|
||
xau,
|
||
abspath,
|
||
vjoin(upload_vpath, fname),
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(upload_vpath, self.uname),
|
||
at,
|
||
sz,
|
||
self.ip,
|
||
at,
|
||
"",
|
||
)
|
||
if not hr:
|
||
t = "upload blocked by xau server config"
|
||
self.log(t, 1)
|
||
wunlink(self.log, abspath, vfs.flags)
|
||
raise Pebkac(403, t)
|
||
if hr.get("reloc"):
|
||
zs = vjoin(upload_vpath, fname)
|
||
x = pathmod(self.asrv.vfs, abspath, zs, hr["reloc"])
|
||
if x:
|
||
if self.args.hook_v:
|
||
log_reloc(
|
||
self.log,
|
||
hr["reloc"],
|
||
x,
|
||
abspath,
|
||
zs,
|
||
fname,
|
||
vfs,
|
||
rem,
|
||
)
|
||
fdir, upload_vpath, fname, (vfs, rem) = x
|
||
ap2 = os.path.join(fdir, fname)
|
||
if nullwrite:
|
||
fdir = ap2 = ""
|
||
else:
|
||
bos.makedirs(fdir)
|
||
atomic_move(self.log, abspath, ap2, vfs.flags)
|
||
abspath = ap2
|
||
sz = bos.path.getsize(abspath)
|
||
|
||
files.append(
|
||
(sz, sha_hex, sha_b64, p_file or "(discarded)", fname, abspath)
|
||
)
|
||
dbv, vrem = vfs.get_dbv(rem)
|
||
self.conn.hsrv.broker.say(
|
||
"up2k.hash_file",
|
||
dbv.realpath,
|
||
vfs.vpath,
|
||
dbv.flags,
|
||
vrem,
|
||
fname,
|
||
self.ip,
|
||
at,
|
||
self.uname,
|
||
True,
|
||
)
|
||
self.conn.nbyte += sz
|
||
|
||
except Pebkac:
|
||
self.parser.drop()
|
||
raise
|
||
|
||
except Pebkac as ex:
|
||
errmsg = vol_san(
|
||
list(self.asrv.vfs.all_vols.values()), unicode(ex).encode("utf-8")
|
||
).decode("utf-8")
|
||
try:
|
||
got = bos.path.getsize(tabspath)
|
||
t = "connection lost after receiving %s of the file"
|
||
self.log(t % (humansize(got),), 3)
|
||
except:
|
||
pass
|
||
|
||
td = max(0.1, time.time() - t0)
|
||
sz_total = sum(x[0] for x in files)
|
||
spd = (sz_total / td) / (1024 * 1024)
|
||
|
||
status = "OK"
|
||
if errmsg:
|
||
self.log(errmsg, 3)
|
||
status = "ERROR"
|
||
|
||
msg = "{} // {} bytes // {:.3f} MiB/s\n".format(status, sz_total, spd)
|
||
jmsg: dict[str, Any] = {
|
||
"status": status,
|
||
"sz": sz_total,
|
||
"mbps": round(spd, 3),
|
||
"files": [],
|
||
}
|
||
|
||
if errmsg:
|
||
msg += errmsg + "\n"
|
||
jmsg["error"] = errmsg
|
||
errmsg = "ERROR: " + errmsg
|
||
|
||
if halg:
|
||
file_fmt = '{0}: {1} // {2} // {3} bytes // <a href="/{4}">{5}</a> {6}\n'
|
||
else:
|
||
file_fmt = '{3} bytes // <a href="/{4}">{5}</a> {6}\n'
|
||
|
||
for sz, sha_hex, sha_b64, ofn, lfn, ap in files:
|
||
vsuf = ""
|
||
if (self.can_read or self.can_upget) and "fk" in vfs.flags:
|
||
st = A_FILE if nullwrite else bos.stat(ap)
|
||
alg = 2 if "fka" in vfs.flags else 1
|
||
vsuf = "?k=" + self.gen_fk(
|
||
alg,
|
||
self.args.fk_salt,
|
||
ap,
|
||
st.st_size,
|
||
0 if ANYWIN or not ap else st.st_ino,
|
||
)[: vfs.flags["fk"]]
|
||
|
||
if "media" in self.uparam or "medialinks" in vfs.flags:
|
||
vsuf += "&v" if vsuf else "?v"
|
||
|
||
vpath = "{}/{}".format(upload_vpath, lfn).strip("/")
|
||
rel_url = quotep(self.args.RS + vpath) + vsuf
|
||
msg += file_fmt.format(
|
||
halg,
|
||
sha_hex[:56],
|
||
sha_b64,
|
||
sz,
|
||
rel_url,
|
||
html_escape(ofn, crlf=True),
|
||
vsuf,
|
||
)
|
||
# truncated SHA-512 prevents length extension attacks;
|
||
# using SHA-512/224, optionally SHA-512/256 = :64
|
||
jpart = {
|
||
"url": "{}://{}/{}".format(
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
rel_url,
|
||
),
|
||
"sz": sz,
|
||
"fn": lfn,
|
||
"fn_orig": ofn,
|
||
"path": rel_url,
|
||
}
|
||
if halg:
|
||
jpart[halg] = sha_hex[:56]
|
||
jpart["sha_b64"] = sha_b64
|
||
jmsg["files"].append(jpart)
|
||
|
||
vspd = self._spd(sz_total, False)
|
||
self.log("%s %r" % (vspd, msg))
|
||
|
||
suf = ""
|
||
if not nullwrite and self.args.write_uplog:
|
||
try:
|
||
log_fn = "up.{:.6f}.txt".format(t0)
|
||
with open(log_fn, "wb") as f:
|
||
ft = "{}:{}".format(self.ip, self.addr[1])
|
||
ft = "{}\n{}\n{}\n".format(ft, msg.rstrip(), errmsg)
|
||
f.write(ft.encode("utf-8"))
|
||
except Exception as ex:
|
||
suf = "\nfailed to write the upload report: {}".format(ex)
|
||
|
||
sc = 400 if errmsg else 201
|
||
if want_url:
|
||
msg = "\n".join([x["url"] for x in jmsg["files"]])
|
||
if errmsg:
|
||
msg += "\n" + errmsg
|
||
|
||
self.reply(msg.encode("utf-8", "replace"), status=sc)
|
||
elif want_json:
|
||
if len(jmsg["files"]) == 1:
|
||
jmsg["fileurl"] = jmsg["files"][0]["url"]
|
||
jtxt = json.dumps(jmsg, indent=2, sort_keys=True).encode("utf-8", "replace")
|
||
self.reply(jtxt, mime="application/json", status=sc)
|
||
else:
|
||
self.redirect(
|
||
self.vpath,
|
||
msg=msg + suf,
|
||
flavor="return to",
|
||
click=False,
|
||
status=sc,
|
||
)
|
||
|
||
if errmsg:
|
||
return False
|
||
|
||
self.parser.drop()
|
||
return True
|
||
|
||
def handle_text_upload(self) -> bool:
|
||
assert self.parser # !rm
|
||
try:
|
||
cli_lastmod3 = int(self.parser.require("lastmod", 16))
|
||
except:
|
||
raise Pebkac(400, "could not read lastmod from request")
|
||
|
||
nullwrite = self.args.nw
|
||
vfs, rem = self.asrv.vfs.get(self.vpath, self.uname, True, True)
|
||
self._assert_safe_rem(rem)
|
||
|
||
clen = int(self.headers.get("content-length", -1))
|
||
if clen == -1:
|
||
raise Pebkac(411)
|
||
|
||
rp, fn = vsplit(rem)
|
||
fp = vfs.canonical(rp)
|
||
lim = vfs.get_dbv(rem)[0].lim
|
||
if lim:
|
||
fp, rp = lim.all(self.ip, rp, clen, vfs.realpath, fp, self.conn.hsrv.broker)
|
||
bos.makedirs(fp)
|
||
|
||
fp = os.path.join(fp, fn)
|
||
rem = "{}/{}".format(rp, fn).strip("/")
|
||
dbv, vrem = vfs.get_dbv(rem)
|
||
|
||
if not rem.endswith(".md") and not self.can_delete:
|
||
raise Pebkac(400, "only markdown pls")
|
||
|
||
if nullwrite:
|
||
response = json.dumps({"ok": True, "lastmod": 0})
|
||
self.log(response)
|
||
# TODO reply should parser.drop()
|
||
self.parser.drop()
|
||
self.reply(response.encode("utf-8"))
|
||
return True
|
||
|
||
srv_lastmod = -1.0
|
||
srv_lastmod3 = -1
|
||
try:
|
||
st = bos.stat(fp)
|
||
srv_lastmod = st.st_mtime
|
||
srv_lastmod3 = int(srv_lastmod * 1000)
|
||
except OSError as ex:
|
||
if ex.errno != errno.ENOENT:
|
||
raise
|
||
|
||
# if file exists, check that timestamp matches the client's
|
||
if srv_lastmod >= 0:
|
||
same_lastmod = cli_lastmod3 in [-1, srv_lastmod3]
|
||
if not same_lastmod:
|
||
# some filesystems/transports limit precision to 1sec, hopefully floored
|
||
same_lastmod = (
|
||
srv_lastmod == int(cli_lastmod3 / 1000)
|
||
and cli_lastmod3 > srv_lastmod3
|
||
and cli_lastmod3 - srv_lastmod3 < 1000
|
||
)
|
||
|
||
if not same_lastmod:
|
||
response = json.dumps(
|
||
{
|
||
"ok": False,
|
||
"lastmod": srv_lastmod3,
|
||
"now": int(time.time() * 1000),
|
||
}
|
||
)
|
||
self.log(
|
||
"{} - {} = {}".format(
|
||
srv_lastmod3, cli_lastmod3, srv_lastmod3 - cli_lastmod3
|
||
)
|
||
)
|
||
self.log(response)
|
||
self.parser.drop()
|
||
self.reply(response.encode("utf-8"))
|
||
return True
|
||
|
||
mdir, mfile = os.path.split(fp)
|
||
fname, fext = mfile.rsplit(".", 1) if "." in mfile else (mfile, "md")
|
||
mfile2 = "{}.{:.3f}.{}".format(fname, srv_lastmod, fext)
|
||
|
||
dp = ""
|
||
hist_cfg = dbv.flags["md_hist"]
|
||
if hist_cfg == "v":
|
||
vrd = vsplit(vrem)[0]
|
||
zb = hashlib.sha512(afsenc(vrd)).digest()
|
||
zs = ub64enc(zb).decode("ascii")[:24].lower()
|
||
dp = "%s/md/%s/%s/%s" % (dbv.histpath, zs[:2], zs[2:4], zs)
|
||
self.log("moving old version to %s/%s" % (dp, mfile2))
|
||
if bos.makedirs(dp):
|
||
with open(os.path.join(dp, "dir.txt"), "wb") as f:
|
||
f.write(afsenc(vrd))
|
||
elif hist_cfg == "s":
|
||
dp = os.path.join(mdir, ".hist")
|
||
try:
|
||
bos.mkdir(dp)
|
||
hidedir(dp)
|
||
except:
|
||
pass
|
||
if dp:
|
||
atomic_move(self.log, fp, os.path.join(dp, mfile2), vfs.flags)
|
||
|
||
assert self.parser.gen # !rm
|
||
p_field, _, p_data = next(self.parser.gen)
|
||
if p_field != "body":
|
||
raise Pebkac(400, "expected body, got {}".format(p_field))
|
||
|
||
xbu = vfs.flags.get("xbu")
|
||
if xbu:
|
||
if not runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xbu.http.txt",
|
||
xbu,
|
||
fp,
|
||
self.vpath,
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(self.vpath, self.uname),
|
||
time.time(),
|
||
0,
|
||
self.ip,
|
||
time.time(),
|
||
"",
|
||
):
|
||
t = "save blocked by xbu server config"
|
||
self.log(t, 1)
|
||
raise Pebkac(403, t)
|
||
|
||
if bos.path.exists(fp):
|
||
wunlink(self.log, fp, vfs.flags)
|
||
|
||
with open(fsenc(fp), "wb", self.args.iobuf) as f:
|
||
sz, sha512, _ = hashcopy(p_data, f, None, 0, self.args.s_wr_slp)
|
||
|
||
if lim:
|
||
lim.nup(self.ip)
|
||
lim.bup(self.ip, sz)
|
||
try:
|
||
lim.chk_sz(sz)
|
||
lim.chk_vsz(self.conn.hsrv.broker, vfs.realpath, sz)
|
||
except:
|
||
wunlink(self.log, fp, vfs.flags)
|
||
raise
|
||
|
||
new_lastmod = bos.stat(fp).st_mtime
|
||
new_lastmod3 = int(new_lastmod * 1000)
|
||
sha512 = sha512[:56]
|
||
|
||
xau = vfs.flags.get("xau")
|
||
if xau and not runhook(
|
||
self.log,
|
||
self.conn.hsrv.broker,
|
||
None,
|
||
"xau.http.txt",
|
||
xau,
|
||
fp,
|
||
self.vpath,
|
||
self.host,
|
||
self.uname,
|
||
self.asrv.vfs.get_perms(self.vpath, self.uname),
|
||
new_lastmod,
|
||
sz,
|
||
self.ip,
|
||
new_lastmod,
|
||
"",
|
||
):
|
||
t = "save blocked by xau server config"
|
||
self.log(t, 1)
|
||
wunlink(self.log, fp, vfs.flags)
|
||
raise Pebkac(403, t)
|
||
|
||
self.conn.hsrv.broker.say(
|
||
"up2k.hash_file",
|
||
dbv.realpath,
|
||
dbv.vpath,
|
||
dbv.flags,
|
||
vsplit(vrem)[0],
|
||
fn,
|
||
self.ip,
|
||
new_lastmod,
|
||
self.uname,
|
||
True,
|
||
)
|
||
|
||
response = json.dumps(
|
||
{"ok": True, "lastmod": new_lastmod3, "size": sz, "sha512": sha512}
|
||
)
|
||
self.log(response)
|
||
self.parser.drop()
|
||
self.reply(response.encode("utf-8"))
|
||
return True
|
||
|
||
def _chk_lastmod(self, file_ts: int) -> tuple[str, bool, bool]:
|
||
# ret: lastmod, do_send, can_range
|
||
file_lastmod = formatdate(file_ts)
|
||
c_ifrange = self.headers.get("if-range")
|
||
c_lastmod = self.headers.get("if-modified-since")
|
||
|
||
if not c_ifrange and not c_lastmod:
|
||
return file_lastmod, True, True
|
||
|
||
if c_ifrange and c_ifrange != file_lastmod:
|
||
t = "sending entire file due to If-Range; cli(%s) file(%s)"
|
||
self.log(t % (c_ifrange, file_lastmod), 6)
|
||
return file_lastmod, True, False
|
||
|
||
do_send = c_lastmod != file_lastmod
|
||
if do_send and c_lastmod:
|
||
t = "sending body due to If-Modified-Since cli(%s) file(%s)"
|
||
self.log(t % (c_lastmod, file_lastmod), 6)
|
||
elif not do_send and self.no304():
|
||
do_send = True
|
||
self.log("sending body due to no304")
|
||
|
||
return file_lastmod, do_send, True
|
||
|
||
def _use_dirkey(self, vn: VFS, ap: str) -> bool:
|
||
if self.can_read or not self.can_get:
|
||
return False
|
||
|
||
if vn.flags.get("dky"):
|
||
return True
|
||
|
||
req = self.uparam.get("k") or ""
|
||
if not req:
|
||
return False
|
||
|
||
dk_len = vn.flags.get("dk")
|
||
if not dk_len:
|
||
return False
|
||
|
||
if not ap:
|
||
ap = vn.canonical(self.rem)
|
||
|
||
zs = self.gen_fk(2, self.args.dk_salt, ap, 0, 0)[:dk_len]
|
||
if req == zs:
|
||
return True
|
||
|
||
t = "wrong dirkey, want %s, got %s\n vp: %r\n ap: %r"
|
||
self.log(t % (zs, req, self.req, ap), 6)
|
||
return False
|
||
|
||
def _use_filekey(self, vn: VFS, ap: str, st: os.stat_result) -> bool:
|
||
if self.can_read or not self.can_get:
|
||
return False
|
||
|
||
req = self.uparam.get("k") or ""
|
||
if not req:
|
||
return False
|
||
|
||
fk_len = vn.flags.get("fk")
|
||
if not fk_len:
|
||
return False
|
||
|
||
if not ap:
|
||
ap = self.vn.canonical(self.rem)
|
||
|
||
alg = 2 if "fka" in vn.flags else 1
|
||
|
||
zs = self.gen_fk(
|
||
alg, self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino
|
||
)[:fk_len]
|
||
|
||
if req == zs:
|
||
return True
|
||
|
||
t = "wrong filekey, want %s, got %s\n vp: %r\n ap: %r"
|
||
self.log(t % (zs, req, self.req, ap), 6)
|
||
return False
|
||
|
||
def _add_logues(
|
||
self, vn: VFS, abspath: str, lnames: Optional[dict[str, str]]
|
||
) -> tuple[list[str], list[str]]:
|
||
logues = ["", ""]
|
||
if not self.args.no_logues:
|
||
for n, fn in LOGUES:
|
||
if lnames is not None and fn not in lnames:
|
||
continue
|
||
fn = "%s/%s" % (abspath, fn)
|
||
if bos.path.isfile(fn):
|
||
logues[n] = read_utf8(self.log, fsenc(fn), False)
|
||
if "exp" in vn.flags:
|
||
logues[n] = self._expand(
|
||
logues[n], vn.flags.get("exp_lg") or []
|
||
)
|
||
|
||
readmes = ["", ""]
|
||
for n, fns in [] if self.args.no_readme else READMES:
|
||
if logues[n]:
|
||
continue
|
||
elif lnames is None:
|
||
pass
|
||
elif fns[0] in lnames:
|
||
fns = [lnames[fns[0]]]
|
||
else:
|
||
fns = []
|
||
|
||
txt = ""
|
||
for fn in fns:
|
||
fn = "%s/%s" % (abspath, fn)
|
||
if bos.path.isfile(fn):
|
||
txt = read_utf8(self.log, fsenc(fn), False)
|
||
break
|
||
|
||
if txt and "exp" in vn.flags:
|
||
txt = self._expand(txt, vn.flags.get("exp_md") or [])
|
||
|
||
readmes[n] = txt
|
||
|
||
return logues, readmes
|
||
|
||
def _expand(self, txt: str, phs: list[str]) -> str:
|
||
ptn_hsafe = RE_HSAFE
|
||
for ph in phs:
|
||
if ph.startswith("hdr."):
|
||
sv = str(self.headers.get(ph[4:], ""))
|
||
elif ph.startswith("self."):
|
||
sv = str(getattr(self, ph[5:], ""))
|
||
elif ph.startswith("cfg."):
|
||
sv = str(getattr(self.args, ph[4:], ""))
|
||
elif ph.startswith("vf."):
|
||
sv = str(self.vn.flags.get(ph[3:]) or "")
|
||
elif ph == "srv.itime":
|
||
sv = str(int(time.time()))
|
||
elif ph == "srv.htime":
|
||
sv = datetime.now(UTC).strftime("%Y-%m-%d, %H:%M:%S")
|
||
else:
|
||
self.log("unknown placeholder in server config: [%s]" % (ph,), 3)
|
||
continue
|
||
|
||
sv = ptn_hsafe.sub("_", sv)
|
||
txt = txt.replace("{{%s}}" % (ph,), sv)
|
||
|
||
return txt
|
||
|
||
def _can_tail(self, volflags: dict[str, Any]) -> bool:
|
||
lvl = volflags["tail_who"]
|
||
if "notail" in volflags or not lvl:
|
||
raise Pebkac(400, "tail is disabled in server config")
|
||
elif lvl <= 1 and not self.can_admin:
|
||
raise Pebkac(400, "tail is admin-only on this server")
|
||
elif lvl <= 2 and self.uname in ("", "*"):
|
||
raise Pebkac(400, "you must be authenticated to use ?tail on this server")
|
||
return True
|
||
|
||
def _can_zip(self, volflags: dict[str, Any]) -> str:
|
||
lvl = volflags["zip_who"]
|
||
if self.args.no_zip or not lvl:
|
||
return "download-as-zip/tar is disabled in server config"
|
||
elif lvl <= 1 and not self.can_admin:
|
||
return "download-as-zip/tar is admin-only on this server"
|
||
elif lvl <= 2 and self.uname in ("", "*"):
|
||
return "you must be authenticated to download-as-zip/tar on this server"
|
||
elif self.args.ua_nozip and self.args.ua_nozip.search(self.ua):
|
||
t = "this URL contains no valuable information for bots/crawlers"
|
||
raise Pebkac(403, t)
|
||
return ""
|
||
|
||
def tx_res(self, req_path: str) -> bool:
|
||
status = 200
|
||
logmsg = "{:4} {} ".format("", self.req)
|
||
logtail = ""
|
||
|
||
editions = {}
|
||
file_ts = 0
|
||
|
||
if has_resource(self.E, req_path):
|
||
st = stat_resource(self.E, req_path)
|
||
if st:
|
||
file_ts = max(file_ts, st.st_mtime)
|
||
editions["plain"] = req_path
|
||
|
||
if has_resource(self.E, req_path + ".gz"):
|
||
st = stat_resource(self.E, req_path + ".gz")
|
||
if st:
|
||
file_ts = max(file_ts, st.st_mtime)
|
||
if not st or st.st_mtime > file_ts:
|
||
editions[".gz"] = req_path + ".gz"
|
||
|
||
if not editions:
|
||
return self.tx_404()
|
||
|
||
#
|
||
# if-modified
|
||
|
||
if file_ts > 0:
|
||
file_lastmod, do_send, _ = self._chk_lastmod(int(file_ts))
|
||
self.out_headers["Last-Modified"] = file_lastmod
|
||
if not do_send:
|
||
status = 304
|
||
|
||
if self.can_write:
|
||
self.out_headers["X-Lastmod3"] = str(int(file_ts * 1000))
|
||
else:
|
||
do_send = True
|
||
|
||
#
|
||
# Accept-Encoding and UA decides which edition to send
|
||
|
||
decompress = False
|
||
supported_editions = [
|
||
x.strip()
|
||
for x in self.headers.get("accept-encoding", "").lower().split(",")
|
||
]
|
||
if ".gz" in editions:
|
||
is_compressed = True
|
||
selected_edition = ".gz"
|
||
|
||
if "gzip" not in supported_editions:
|
||
decompress = True
|
||
else:
|
||
if re.match(r"MSIE [4-6]\.", self.ua) and " SV1" not in self.ua:
|
||
decompress = True
|
||
|
||
if not decompress:
|
||
self.out_headers["Content-Encoding"] = "gzip"
|
||
else:
|
||
is_compressed = False
|
||
selected_edition = "plain"
|
||
|
||
res_path = editions[selected_edition]
|
||
logmsg += "{} ".format(selected_edition.lstrip("."))
|
||
|
||
res = load_resource(self.E, res_path)
|
||
|
||
if decompress:
|
||
file_sz = gzip_file_orig_sz(res)
|
||
res = gzip.open(res)
|
||
else:
|
||
res.seek(0, os.SEEK_END)
|
||
file_sz = res.tell()
|
||
res.seek(0, os.SEEK_SET)
|
||
|
||
#
|
||
# send reply
|
||
|
||
if is_compressed:
|
||
self.out_headers["Cache-Control"] = "max-age=604869"
|
||
else:
|
||
self.permit_caching()
|
||
|
||
if "txt" in self.uparam:
|
||
mime = "text/plain; charset={}".format(self.uparam["txt"] or "utf-8")
|
||
elif "mime" in self.uparam:
|
||
mime = str(self.uparam.get("mime"))
|
||
else:
|
||
mime = guess_mime(req_path)
|
||
|
||
logmsg += unicode(status) + logtail
|
||
|
||
if self.mode == "HEAD" or not do_send:
|
||
res.close()
|
||
if self.do_log:
|
||
self.log(logmsg)
|
||
|
||
self.send_headers(length=file_sz, status=status, mime=mime)
|
||
return True
|
||
|
||
ret = True
|
||
self.send_headers(length=file_sz, status=status, mime=mime)
|
||
remains = sendfile_py(
|
||
self.log,
|
||
0,
|
||
file_sz,
|
||
res,
|
||
self.s,
|
||
self.args.s_wr_sz,
|
||
self.args.s_wr_slp,
|
||
not self.args.no_poll,
|
||
{},
|
||
"",
|
||
)
|
||
res.close()
|
||
|
||
if remains > 0:
|
||
logmsg += " \033[31m" + unicode(file_sz - remains) + "\033[0m"
|
||
ret = False
|
||
|
||
spd = self._spd(file_sz - remains)
|
||
if self.do_log:
|
||
self.log("{}, {}".format(logmsg, spd))
|
||
|
||
return ret
|
||
|
||
def tx_file(self, req_path: str, ptop: Optional[str] = None) -> bool:
|
||
status = 200
|
||
logmsg = "{:4} {} ".format("", self.req)
|
||
logtail = ""
|
||
|
||
is_tail = "tail" in self.uparam and self._can_tail(self.vn.flags)
|
||
|
||
if ptop is not None:
|
||
ap_data = "<%s>" % (req_path,)
|
||
try:
|
||
dp, fn = os.path.split(req_path)
|
||
tnam = fn + ".PARTIAL"
|
||
if self.args.dotpart:
|
||
tnam = "." + tnam
|
||
ap_data = os.path.join(dp, tnam)
|
||
st_data = bos.stat(ap_data)
|
||
if not st_data.st_size:
|
||
raise Exception("partial is empty")
|
||
x = self.conn.hsrv.broker.ask("up2k.find_job_by_ap", ptop, req_path)
|
||
job = json.loads(x.get())
|
||
if not job:
|
||
raise Exception("not found in registry")
|
||
self.pipes.set(req_path, job)
|
||
except Exception as ex:
|
||
if getattr(ex, "errno", 0) != errno.ENOENT:
|
||
self.log("will not pipe %r; %s" % (ap_data, ex), 6)
|
||
ptop = None
|
||
|
||
#
|
||
# if request is for foo.js, check if we have foo.js.gz
|
||
|
||
file_ts = 0.0
|
||
editions: dict[str, tuple[str, int]] = {}
|
||
for ext in ("", ".gz"):
|
||
if ptop is not None:
|
||
assert job and ap_data # type: ignore # !rm
|
||
sz = job["size"]
|
||
file_ts = max(0, job["lmod"])
|
||
editions["plain"] = (ap_data, sz)
|
||
break
|
||
|
||
try:
|
||
fs_path = req_path + ext
|
||
st = bos.stat(fs_path)
|
||
if stat.S_ISDIR(st.st_mode):
|
||
continue
|
||
|
||
if stat.S_ISBLK(st.st_mode):
|
||
fd = bos.open(fs_path, os.O_RDONLY)
|
||
try:
|
||
sz = os.lseek(fd, 0, os.SEEK_END)
|
||
finally:
|
||
os.close(fd)
|
||
else:
|
||
sz = st.st_size
|
||
|
||
file_ts = max(file_ts, st.st_mtime)
|
||
editions[ext or "plain"] = (fs_path, sz)
|
||
except:
|
||
pass
|
||
if not self.vpath.startswith(".cpr/"):
|
||
break
|
||
|
||
if not editions:
|
||
return self.tx_404()
|
||
|
||
#
|
||
# if-modified
|
||
|
||
file_lastmod, do_send, can_range = self._chk_lastmod(int(file_ts))
|
||
self.out_headers["Last-Modified"] = file_lastmod
|
||
if not do_send:
|
||
status = 304
|
||
|
||
if self.can_write:
|
||
self.out_headers["X-Lastmod3"] = str(int(file_ts * 1000))
|
||
|
||
#
|
||
# Accept-Encoding and UA decides which edition to send
|
||
|
||
decompress = False
|
||
supported_editions = [
|
||
x.strip()
|
||
for x in self.headers.get("accept-encoding", "").lower().split(",")
|
||
]
|
||
if ".gz" in editions:
|
||
is_compressed = True
|
||
selected_edition = ".gz"
|
||
fs_path, file_sz = editions[".gz"]
|
||
if "gzip" not in supported_editions:
|
||
decompress = True
|
||
else:
|
||
if re.match(r"MSIE [4-6]\.", self.ua) and " SV1" not in self.ua:
|
||
decompress = True
|
||
|
||
if not decompress:
|
||
self.out_headers["Content-Encoding"] = "gzip"
|
||
else:
|
||
is_compressed = False
|
||
selected_edition = "plain"
|
||
|
||
fs_path, file_sz = editions[selected_edition]
|
||
logmsg += "{} ".format(selected_edition.lstrip("."))
|
||
|
||
#
|
||
# partial
|
||
|
||
lower = 0
|
||
upper = file_sz
|
||
hrange = self.headers.get("range")
|
||
|
||
# let's not support 206 with compression
|
||
# and multirange / multipart is also not-impl (mostly because calculating contentlength is a pain)
|
||
if (
|
||
do_send
|
||
and not is_compressed
|
||
and hrange
|
||
and can_range
|
||
and file_sz
|
||
and "," not in hrange
|
||
and not is_tail
|
||
):
|
||
try:
|
||
if not hrange.lower().startswith("bytes"):
|
||
raise Exception()
|
||
|
||
a, b = hrange.split("=", 1)[1].split("-")
|
||
|
||
if a.strip():
|
||
lower = int(a.strip())
|
||
else:
|
||
lower = 0
|
||
|
||
if b.strip():
|
||
upper = int(b.strip()) + 1
|
||
else:
|
||
upper = file_sz
|
||
|
||
if upper > file_sz:
|
||
upper = file_sz
|
||
|
||
if lower < 0 or lower >= upper:
|
||
raise Exception()
|
||
|
||
except:
|
||
err = "invalid range ({}), size={}".format(hrange, file_sz)
|
||
self.loud_reply(
|
||
err,
|
||
status=416,
|
||
headers={"Content-Range": "bytes */{}".format(file_sz)},
|
||
)
|
||
return True
|
||
|
||
status = 206
|
||
self.out_headers["Content-Range"] = "bytes {}-{}/{}".format(
|
||
lower, upper - 1, file_sz
|
||
)
|
||
|
||
logtail += " [\033[36m{}-{}\033[0m]".format(lower, upper)
|
||
|
||
use_sendfile = False
|
||
if decompress:
|
||
open_func: Any = gzip.open
|
||
open_args: list[Any] = [fsenc(fs_path), "rb"]
|
||
# Content-Length := original file size
|
||
upper = gzip_orig_sz(fs_path)
|
||
else:
|
||
open_func = open
|
||
open_args = [fsenc(fs_path), "rb", self.args.iobuf]
|
||
use_sendfile = (
|
||
# fmt: off
|
||
not self.tls
|
||
and not self.args.no_sendfile
|
||
and (BITNESS > 32 or file_sz < 0x7fffFFFF)
|
||
# fmt: on
|
||
)
|
||
|
||
#
|
||
# send reply
|
||
|
||
if is_compressed:
|
||
self.out_headers["Cache-Control"] = "max-age=604869"
|
||
else:
|
||
self.permit_caching()
|
||
|
||
if "txt" in self.uparam:
|
||
mime = "text/plain; charset={}".format(self.uparam["txt"] or "utf-8")
|
||
elif "mime" in self.uparam:
|
||
mime = str(self.uparam.get("mime"))
|
||
elif "magic" in self.vn.flags:
|
||
mime = guess_mime(req_path, fsenc(fs_path))
|
||
else:
|
||
mime = guess_mime(req_path)
|
||
|
||
if "nohtml" in self.vn.flags and "html" in mime:
|
||
mime = "text/plain; charset=utf-8"
|
||
|
||
self.out_headers["Accept-Ranges"] = "bytes"
|
||
logmsg += unicode(status) + logtail
|
||
|
||
if self.mode == "HEAD" or not do_send:
|
||
if self.do_log:
|
||
self.log(logmsg)
|
||
|
||
self.send_headers(length=upper - lower, status=status, mime=mime)
|
||
return True
|
||
|
||
dls = self.conn.hsrv.dls
|
||
if is_tail:
|
||
upper = 1 << 30
|
||
if len(dls) > self.args.tail_cmax:
|
||
raise Pebkac(400, "too many active downloads to start a new tail")
|
||
|
||
if upper - lower > 0x400000: # 4m
|
||
now = time.time()
|
||
self.dl_id = "%s:%s" % (self.ip, self.addr[1])
|
||
dls[self.dl_id] = (now, 0)
|
||
self.conn.hsrv.dli[self.dl_id] = (
|
||
now,
|
||
0 if is_tail else upper - lower,
|
||
self.vn,
|
||
self.vpath,
|
||
self.uname,
|
||
)
|
||
|
||
if ptop is not None:
|
||
assert job and ap_data # type: ignore # !rm
|
||
return self.tx_pipe(
|
||
ptop, req_path, ap_data, job, lower, upper, status, mime, logmsg
|
||
)
|
||
elif is_tail:
|
||
self.tx_tail(open_args, status, mime)
|
||
return False
|
||
|
||
ret = True
|
||
with open_func(*open_args) as f:
|
||
self.send_headers(length=upper - lower, status=status, mime=mime)
|
||
|
||
sendfun = sendfile_kern if use_sendfile else sendfile_py
|
||
remains = sendfun(
|
||
self.log,
|
||
lower,
|
||
upper,
|
||
f,
|
||
self.s,
|
||
self.args.s_wr_sz,
|
||
self.args.s_wr_slp,
|
||
not self.args.no_poll,
|
||
dls,
|
||
self.dl_id,
|
||
)
|
||
|
||
if remains > 0:
|
||
logmsg += " \033[31m" + unicode(upper - remains) + "\033[0m"
|
||
ret = False
|
||
|
||
spd = self._spd((upper - lower) - remains)
|
||
if self.do_log:
|
||
self.log("{}, {}".format(logmsg, spd))
|
||
|
||
return ret
|
||
|
||
def tx_tail(
|
||
self,
|
||
open_args: list[Any],
|
||
status: int,
|
||
mime: str,
|
||
) -> None:
|
||
self.send_headers(length=None, status=status, mime=mime)
|
||
abspath: bytes = open_args[0]
|
||
sec_rate = self.args.tail_rate
|
||
sec_ka = self.args.tail_ka
|
||
sec_fd = self.args.tail_fd
|
||
wr_slp = self.args.s_wr_slp
|
||
wr_sz = self.args.s_wr_sz
|
||
dls = self.conn.hsrv.dls
|
||
dl_id = self.dl_id
|
||
|
||
# non-numeric = full file from start
|
||
# positive = absolute offset from start
|
||
# negative = start that many bytes from eof
|
||
try:
|
||
ofs = int(self.uparam["tail"])
|
||
except:
|
||
ofs = 0
|
||
|
||
ofs0 = ofs
|
||
f = None
|
||
try:
|
||
st = os.stat(abspath)
|
||
f = open(*open_args)
|
||
f.seek(0, os.SEEK_END)
|
||
eof = f.tell()
|
||
f.seek(0)
|
||
if ofs < 0:
|
||
ofs = max(0, ofs + eof)
|
||
|
||
self.log("tailing from byte %d: %r" % (ofs, abspath), 6)
|
||
|
||
# send initial data asap
|
||
remains = sendfile_py(
|
||
self.log, # d/c
|
||
ofs,
|
||
eof,
|
||
f,
|
||
self.s,
|
||
wr_sz,
|
||
wr_slp,
|
||
False, # d/c
|
||
dls,
|
||
dl_id,
|
||
)
|
||
sent = (eof - ofs) - remains
|
||
ofs = eof - remains
|
||
f.seek(ofs)
|
||
|
||
try:
|
||
st2 = os.stat(open_args[0])
|
||
if st.st_ino == st2.st_ino:
|
||
st = st2 # for filesize
|
||
except:
|
||
pass
|
||
|
||
gone = 0
|
||
t_fd = t_ka = time.time()
|
||
while True:
|
||
assert f # !rm
|
||
buf = f.read(4096)
|
||
now = time.time()
|
||
if buf:
|
||
t_fd = t_ka = now
|
||
self.s.sendall(buf)
|
||
sent += len(buf)
|
||
dls[dl_id] = (time.time(), sent)
|
||
continue
|
||
|
||
time.sleep(sec_rate)
|
||
if t_ka < now - sec_ka:
|
||
t_ka = now
|
||
self.s.send(b"\x00")
|
||
if t_fd < now - sec_fd:
|
||
try:
|
||
st2 = os.stat(open_args[0])
|
||
if st2.st_ino != st.st_ino or st2.st_size < sent or st2.st_size < st.st_size:
|
||
assert f # !rm
|
||
# open new file before closing previous to avoid toctous (open may fail; cannot null f before)
|
||
f2 = open(*open_args)
|
||
f.close()
|
||
f = f2
|
||
f.seek(0, os.SEEK_END)
|
||
eof = f.tell()
|
||
if eof < sent:
|
||
ofs = sent = 0 # shrunk; send from start
|
||
zb = b"\n\n*** file size decreased -- rewinding to the start of the file ***\n\n"
|
||
self.s.sendall(zb)
|
||
if ofs0 < 0 and eof > -ofs0:
|
||
ofs = eof + ofs0
|
||
else:
|
||
ofs = sent # just new fd? resume from same ofs
|
||
f.seek(ofs)
|
||
self.log("reopened at byte %d: %r" % (ofs, abspath), 6)
|
||
gone = 0
|
||
st = st2
|
||
except:
|
||
gone += 1
|
||
if gone > 3:
|
||
self.log("file deleted; disconnecting")
|
||
break
|
||
except IOError as ex:
|
||
if ex.errno not in (errno.EPIPE, errno.ESHUTDOWN, errno.EBADFD):
|
||
raise
|
||
finally:
|
||
if f:
|
||
f.close()
|
||
|
||
def tx_pipe(
|
||
self,
|
||
ptop: str,
|
||
req_path: str,
|
||
ap_data: str,
|
||
job: dict[str, Any],
|
||
lower: int,
|
||
upper: int,
|
||
status: int,
|
||
mime: str,
|
||
logmsg: str,
|
||
) -> bool:
|
||
M = 1048576
|
||
self.send_headers(length=upper - lower, status=status, mime=mime)
|
||
wr_slp = self.args.s_wr_slp
|
||
wr_sz = self.args.s_wr_sz
|
||
file_size = job["size"]
|
||
chunk_size = up2k_chunksize(file_size)
|
||
num_need = -1
|
||
data_end = 0
|
||
remains = upper - lower
|
||
broken = False
|
||
spins = 0
|
||
tier = 0
|
||
tiers = ["uncapped", "reduced speed", "one byte per sec"]
|
||
|
||
while lower < upper and not broken:
|
||
with self.u2mutex:
|
||
job = self.pipes.get(req_path)
|
||
if not job:
|
||
x = self.conn.hsrv.broker.ask("up2k.find_job_by_ap", ptop, req_path)
|
||
job = json.loads(x.get())
|
||
if job:
|
||
self.pipes.set(req_path, job)
|
||
|
||
if not job:
|
||
t = "pipe: OK, upload has finished; yeeting remainder"
|
||
self.log(t, 2)
|
||
data_end = file_size
|
||
break
|
||
|
||
if num_need != len(job["need"]) and data_end - lower < 8 * M:
|
||
num_need = len(job["need"])
|
||
data_end = 0
|
||
for cid in job["hash"]:
|
||
if cid in job["need"]:
|
||
break
|
||
data_end += chunk_size
|
||
t = "pipe: can stream %.2f MiB; requested range is %.2f to %.2f"
|
||
self.log(t % (data_end / M, lower / M, upper / M), 6)
|
||
with self.u2mutex:
|
||
if data_end > self.u2fh.aps.get(ap_data, data_end):
|
||
fhs: Optional[set[typing.BinaryIO]] = None
|
||
try:
|
||
fhs = self.u2fh.cache[ap_data].all_fhs
|
||
for fh in fhs:
|
||
fh.flush()
|
||
self.u2fh.aps[ap_data] = data_end
|
||
self.log("pipe: flushed %d up2k-FDs" % (len(fhs),))
|
||
except Exception as ex:
|
||
if fhs is None:
|
||
err = "file is not being written to right now"
|
||
else:
|
||
err = repr(ex)
|
||
self.log("pipe: u2fh flush failed: " + err)
|
||
|
||
if lower >= data_end:
|
||
if data_end:
|
||
t = "pipe: uploader is too slow; aborting download at %.2f MiB"
|
||
self.log(t % (data_end / M,))
|
||
raise Pebkac(416, "uploader is too slow")
|
||
|
||
raise Pebkac(416, "no data available yet; please retry in a bit")
|
||
|
||
slack = data_end - lower
|
||
if slack >= 8 * M:
|
||
ntier = 0
|
||
winsz = M
|
||
bufsz = wr_sz
|
||
slp = wr_slp
|
||
else:
|
||
winsz = max(40, int(M * (slack / (12 * M))))
|
||
base_rate = M if not wr_slp else wr_sz / wr_slp
|
||
if winsz > base_rate:
|
||
ntier = 0
|
||
bufsz = wr_sz
|
||
slp = wr_slp
|
||
elif winsz > 300:
|
||
ntier = 1
|
||
bufsz = winsz // 5
|
||
slp = 0.2
|
||
else:
|
||
ntier = 2
|
||
bufsz = winsz = slp = 1
|
||
|
||
if tier != ntier:
|
||
tier = ntier
|
||
self.log("moved to tier %d (%s)" % (tier, tiers[tier]))
|
||
|
||
try:
|
||
with open(ap_data, "rb", self.args.iobuf) as f:
|
||
f.seek(lower)
|
||
page = f.read(min(winsz, data_end - lower, upper - lower))
|
||
if not page:
|
||
raise Exception("got 0 bytes (EOF?)")
|
||
except Exception as ex:
|
||
self.log("pipe: read failed at %.2f MiB: %s" % (lower / M, ex), 3)
|
||
with self.u2mutex:
|
||
self.pipes.c.pop(req_path, None)
|
||
spins += 1
|
||
if spins > 3:
|
||
raise Pebkac(500, "file became unreadable")
|
||
time.sleep(2)
|
||
continue
|
||
|
||
spins = 0
|
||
pofs = 0
|
||
while pofs < len(page):
|
||
if slp:
|
||
time.sleep(slp)
|
||
|
||
try:
|
||
buf = page[pofs : pofs + bufsz]
|
||
self.s.sendall(buf)
|
||
zi = len(buf)
|
||
remains -= zi
|
||
lower += zi
|
||
pofs += zi
|
||
except:
|
||
broken = True
|
||
break
|
||
|
||
if lower < upper and not broken:
|
||
with open(req_path, "rb") as f:
|
||
remains = sendfile_py(
|
||
self.log,
|
||
lower,
|
||
upper,
|
||
f,
|
||
self.s,
|
||
wr_sz,
|
||
wr_slp,
|
||
not self.args.no_poll,
|
||
self.conn.hsrv.dls,
|
||
self.dl_id,
|
||
)
|
||
|
||
spd = self._spd((upper - lower) - remains)
|
||
if self.do_log:
|
||
self.log("{}, {}".format(logmsg, spd))
|
||
|
||
return not broken
|
||
|
||
def tx_zip(
|
||
self,
|
||
fmt: str,
|
||
uarg: str,
|
||
vpath: str,
|
||
vn: VFS,
|
||
rem: str,
|
||
items: list[str],
|
||
) -> bool:
|
||
t = self._can_zip(vn.flags)
|
||
if t:
|
||
raise Pebkac(400, t)
|
||
|
||
logmsg = "{:4} {} ".format("", self.req)
|
||
self.keepalive = False
|
||
|
||
cancmp = not self.args.no_tarcmp
|
||
|
||
if fmt == "tar":
|
||
packer: Type[StreamArc] = StreamTar
|
||
if cancmp and "gz" in uarg:
|
||
mime = "application/gzip"
|
||
ext = "tar.gz"
|
||
elif cancmp and "bz2" in uarg:
|
||
mime = "application/x-bzip"
|
||
ext = "tar.bz2"
|
||
elif cancmp and "xz" in uarg:
|
||
mime = "application/x-xz"
|
||
ext = "tar.xz"
|
||
else:
|
||
mime = "application/x-tar"
|
||
ext = "tar"
|
||
else:
|
||
mime = "application/zip"
|
||
packer = StreamZip
|
||
ext = "zip"
|
||
|
||
fn = items[0] if items and items[0] else self.vpath
|
||
if fn:
|
||
fn = fn.rstrip("/").split("/")[-1]
|
||
else:
|
||
fn = self.host.split(":")[0]
|
||
|
||
if vn.flags.get("zipmax") and (not self.uname or not "zipmaxu" in vn.flags):
|
||
maxs = vn.flags.get("zipmaxs_v") or 0
|
||
maxn = vn.flags.get("zipmaxn_v") or 0
|
||
nf = 0
|
||
nb = 0
|
||
fgen = vn.zipgen(
|
||
vpath, rem, set(items), self.uname, False, not self.args.no_scandir
|
||
)
|
||
t = "total size exceeds a limit specified in server config"
|
||
t = vn.flags.get("zipmaxt") or t
|
||
if maxs and maxn:
|
||
for zd in fgen:
|
||
nf += 1
|
||
nb += zd["st"].st_size
|
||
if maxs < nb or maxn < nf:
|
||
raise Pebkac(400, t)
|
||
elif maxs:
|
||
for zd in fgen:
|
||
nb += zd["st"].st_size
|
||
if maxs < nb:
|
||
raise Pebkac(400, t)
|
||
elif maxn:
|
||
for zd in fgen:
|
||
nf += 1
|
||
if maxn < nf:
|
||
raise Pebkac(400, t)
|
||
|
||
safe = (string.ascii_letters + string.digits).replace("%", "")
|
||
afn = "".join([x if x in safe.replace('"', "") else "_" for x in fn])
|
||
bascii = unicode(safe).encode("utf-8")
|
||
zb = fn.encode("utf-8", "xmlcharrefreplace")
|
||
if not PY2:
|
||
zbl = [
|
||
chr(x).encode("utf-8")
|
||
if x in bascii
|
||
else "%{:02x}".format(x).encode("ascii")
|
||
for x in zb
|
||
]
|
||
else:
|
||
zbl = [unicode(x) if x in bascii else "%{:02x}".format(ord(x)) for x in zb]
|
||
|
||
ufn = b"".join(zbl).decode("ascii")
|
||
|
||
cdis = "attachment; filename=\"{}.{}\"; filename*=UTF-8''{}.{}"
|
||
cdis = cdis.format(afn, ext, ufn, ext)
|
||
self.log(repr(cdis))
|
||
self.send_headers(None, mime=mime, headers={"Content-Disposition": cdis})
|
||
|
||
fgen = vn.zipgen(
|
||
vpath, rem, set(items), self.uname, False, not self.args.no_scandir
|
||
)
|
||
# for f in fgen: print(repr({k: f[k] for k in ["vp", "ap"]}))
|
||
cfmt = ""
|
||
if self.thumbcli and not self.args.no_bacode:
|
||
for zs in ("opus", "mp3", "w", "j", "p"):
|
||
if zs in self.ouparam or uarg == zs:
|
||
cfmt = zs
|
||
|
||
if cfmt:
|
||
self.log("transcoding to [{}]".format(cfmt))
|
||
fgen = gfilter(fgen, self.thumbcli, self.uname, vpath, cfmt)
|
||
|
||
now = time.time()
|
||
self.dl_id = "%s:%s" % (self.ip, self.addr[1])
|
||
self.conn.hsrv.dli[self.dl_id] = (
|
||
now,
|
||
0,
|
||
self.vn,
|
||
"%s :%s" % (self.vpath, ext),
|
||
self.uname,
|
||
)
|
||
dls = self.conn.hsrv.dls
|
||
dls[self.dl_id] = (time.time(), 0)
|
||
|
||
bgen = packer(
|
||
self.log,
|
||
self.asrv,
|
||
fgen,
|
||
utf8="utf" in uarg or not uarg,
|
||
pre_crc="crc" in uarg,
|
||
cmp=uarg if cancmp or uarg == "pax" else "",
|
||
)
|
||
n = 0
|
||
bsent = 0
|
||
for buf in bgen.gen():
|
||
if not buf:
|
||
break
|
||
|
||
try:
|
||
self.s.sendall(buf)
|
||
bsent += len(buf)
|
||
except:
|
||
logmsg += " \033[31m" + unicode(bsent) + "\033[0m"
|
||
bgen.stop()
|
||
break
|
||
|
||
n += 1
|
||
if n >= 4:
|
||
n = 0
|
||
dls[self.dl_id] = (time.time(), bsent)
|
||
|
||
spd = self._spd(bsent)
|
||
self.log("{}, {}".format(logmsg, spd))
|
||
return True
|
||
|
||
def tx_ico(self, ext: str, exact: bool = False) -> bool:
|
||
self.permit_caching()
|
||
if ext.endswith("/"):
|
||
ext = "folder"
|
||
exact = True
|
||
|
||
bad = re.compile(r"[](){}/ []|^[0-9_-]*$")
|
||
n = ext.split(".")[::-1]
|
||
if not exact:
|
||
n = n[:-1]
|
||
|
||
ext = ""
|
||
for v in n:
|
||
if len(v) > 7 or bad.search(v):
|
||
break
|
||
|
||
ext = "{}.{}".format(v, ext)
|
||
|
||
ext = ext.rstrip(".") or "unk"
|
||
if len(ext) > 11:
|
||
ext = "~" + ext[-9:]
|
||
|
||
return self.tx_svg(ext, exact)
|
||
|
||
def tx_svg(self, txt: str, small: bool = False) -> bool:
|
||
# chrome cannot handle more than ~2000 unique SVGs
|
||
# so url-param "raster" returns a png/webp instead
|
||
# (useragent-sniffing kinshi due to caching proxies)
|
||
mime, ico = self.ico.get(txt, not small, "raster" in self.uparam)
|
||
|
||
lm = formatdate(self.E.t0)
|
||
self.reply(ico, mime=mime, headers={"Last-Modified": lm})
|
||
return True
|
||
|
||
def tx_qr(self):
|
||
url = "%s://%s%s%s" % (
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
self.args.SRS,
|
||
self.vpaths,
|
||
)
|
||
uhash = ""
|
||
uparams = []
|
||
if self.ouparam:
|
||
for k, v in self.ouparam.items():
|
||
if k == "qr":
|
||
continue
|
||
if k == "uhash":
|
||
uhash = v
|
||
continue
|
||
uparams.append(k if v == "" else "%s=%s" % (k, v))
|
||
if uparams:
|
||
url += "?" + "&".join(uparams)
|
||
if uhash:
|
||
url += "#" + uhash
|
||
|
||
self.log("qrcode(%r)" % (url,))
|
||
ret = qr2svg(QrCode.encode_binary(url.encode("utf-8")), 2)
|
||
self.reply(ret.encode("utf-8"), mime="image/svg+xml")
|
||
return True
|
||
|
||
def tx_md(self, vn: VFS, fs_path: str) -> bool:
|
||
logmsg = " %s @%s " % (self.req, self.uname)
|
||
|
||
if not self.can_write:
|
||
if "edit" in self.uparam or "edit2" in self.uparam:
|
||
return self.tx_404(True)
|
||
|
||
tpl = "mde" if "edit2" in self.uparam else "md"
|
||
template = self.j2j(tpl)
|
||
|
||
st = bos.stat(fs_path)
|
||
ts_md = st.st_mtime
|
||
|
||
max_sz = 1024 * self.args.txt_max
|
||
sz_md = 0
|
||
lead = b""
|
||
fullfile = b""
|
||
for buf in yieldfile(fs_path, self.args.iobuf):
|
||
if sz_md < max_sz:
|
||
fullfile += buf
|
||
else:
|
||
fullfile = b""
|
||
|
||
if not sz_md and b"\n" in buf[:2]:
|
||
lead = buf[: buf.find(b"\n") + 1]
|
||
sz_md += len(lead)
|
||
|
||
sz_md += len(buf)
|
||
for c, v in [(b"&", 4), (b"<", 3), (b">", 3)]:
|
||
sz_md += (len(buf) - len(buf.replace(c, b""))) * v
|
||
|
||
if (
|
||
fullfile
|
||
and "exp" in vn.flags
|
||
and "edit" not in self.uparam
|
||
and "edit2" not in self.uparam
|
||
and vn.flags.get("exp_md")
|
||
):
|
||
fulltxt = fullfile.decode("utf-8", "replace")
|
||
fulltxt = self._expand(fulltxt, vn.flags.get("exp_md") or [])
|
||
fullfile = fulltxt.encode("utf-8", "replace")
|
||
|
||
if fullfile:
|
||
fullfile = html_bescape(fullfile)
|
||
sz_md = len(lead) + len(fullfile)
|
||
|
||
file_ts = int(max(ts_md, self.E.t0))
|
||
file_lastmod, do_send, _ = self._chk_lastmod(file_ts)
|
||
self.out_headers["Last-Modified"] = file_lastmod
|
||
self.out_headers.update(NO_CACHE)
|
||
status = 200 if do_send else 304
|
||
|
||
arg_base = "?"
|
||
if "k" in self.uparam:
|
||
arg_base = "?k={}&".format(self.uparam["k"])
|
||
|
||
boundary = "\roll\tide"
|
||
targs = {
|
||
"r": self.args.SR if self.is_vproxied else "",
|
||
"ts": self.conn.hsrv.cachebuster(),
|
||
"edit": "edit" in self.uparam,
|
||
"title": html_escape(self.vpath, crlf=True),
|
||
"lastmod": int(ts_md * 1000),
|
||
"lang": self.args.lang,
|
||
"favico": self.args.favico,
|
||
"have_emp": self.args.emp,
|
||
"md_chk_rate": self.args.mcr,
|
||
"md": boundary,
|
||
"arg_base": arg_base,
|
||
}
|
||
|
||
if self.args.js_other and "js" not in targs:
|
||
zs = self.args.js_other
|
||
zs += "&" if "?" in zs else "?"
|
||
targs["js"] = zs
|
||
|
||
zfv = self.vn.flags.get("html_head")
|
||
if zfv:
|
||
targs["this"] = self
|
||
self._build_html_head(zfv, targs)
|
||
|
||
targs["html_head"] = self.html_head
|
||
zs = template.render(**targs).encode("utf-8", "replace")
|
||
html = zs.split(boundary.encode("utf-8"))
|
||
if len(html) != 2:
|
||
raise Exception("boundary appears in " + tpl)
|
||
|
||
self.send_headers(sz_md + len(html[0]) + len(html[1]), status)
|
||
|
||
logmsg += unicode(status)
|
||
if self.mode == "HEAD" or not do_send:
|
||
if self.do_log:
|
||
self.log(logmsg)
|
||
|
||
return True
|
||
|
||
try:
|
||
self.s.sendall(html[0] + lead)
|
||
if fullfile:
|
||
self.s.sendall(fullfile)
|
||
else:
|
||
for buf in yieldfile(fs_path, self.args.iobuf):
|
||
self.s.sendall(html_bescape(buf))
|
||
|
||
self.s.sendall(html[1])
|
||
|
||
except:
|
||
self.log(logmsg + " \033[31md/c\033[0m")
|
||
return False
|
||
|
||
if self.do_log:
|
||
self.log(logmsg + " " + unicode(len(html)))
|
||
|
||
return True
|
||
|
||
def tx_svcs(self) -> bool:
|
||
aname = re.sub("[^0-9a-zA-Z]+", "", self.args.vname) or "a"
|
||
ep = self.host
|
||
host = ep.split(":")[0]
|
||
hport = ep[ep.find(":") :] if ":" in ep else ""
|
||
rip = (
|
||
host
|
||
if self.args.rclone_mdns or not self.args.zm
|
||
else self.conn.hsrv.nm.map(self.ip) or host
|
||
)
|
||
# safer than html_escape/quotep since this avoids both XSS and shell-stuff
|
||
pw = re.sub(r"[<>&$?`\"']", "_", self.pw or "hunter2")
|
||
vp = re.sub(r"[<>&$?`\"']", "_", self.uparam["hc"] or "").lstrip("/")
|
||
pw = pw.replace(" ", "%20")
|
||
vp = vp.replace(" ", "%20")
|
||
if pw in self.asrv.sesa:
|
||
pw = "hunter2"
|
||
|
||
html = self.j2s(
|
||
"svcs",
|
||
args=self.args,
|
||
accs=bool(self.asrv.acct),
|
||
s="s" if self.is_https else "",
|
||
rip=rip,
|
||
ep=ep,
|
||
vp=vp,
|
||
rvp=vjoin(self.args.R, vp),
|
||
host=host,
|
||
hport=hport,
|
||
aname=aname,
|
||
pw=pw,
|
||
)
|
||
self.reply(html.encode("utf-8"))
|
||
return True
|
||
|
||
def tx_mounts(self) -> bool:
|
||
suf = self.urlq({}, ["h"])
|
||
rvol, wvol, avol = [
|
||
[("/" + x).rstrip("/") + "/" for x in y]
|
||
for y in [self.rvol, self.wvol, self.avol]
|
||
]
|
||
|
||
ups = []
|
||
now = time.time()
|
||
get_vst = self.avol and not self.args.no_rescan
|
||
get_ups = self.rvol and not self.args.no_up_list and self.uname or ""
|
||
if get_vst or get_ups:
|
||
x = self.conn.hsrv.broker.ask("up2k.get_state", get_vst, get_ups)
|
||
vs = json.loads(x.get())
|
||
vstate = {("/" + k).rstrip("/") + "/": v for k, v in vs["volstate"].items()}
|
||
try:
|
||
for rem, sz, t0, poke, vp in vs["ups"]:
|
||
fdone = max(0.001, 1 - rem)
|
||
td = max(0.1, now - t0)
|
||
rd, fn = vsplit(vp.replace(os.sep, "/"))
|
||
if not rd:
|
||
rd = "/"
|
||
erd = quotep(rd)
|
||
rds = rd.replace("/", " / ")
|
||
spd = humansize(sz * fdone / td, True) + "/s"
|
||
eta = s2hms((td / fdone) - td, True) if rem < 1 else "--"
|
||
idle = s2hms(now - poke, True)
|
||
ups.append((int(100 * fdone), spd, eta, idle, erd, rds, fn))
|
||
except Exception as ex:
|
||
self.log("failed to list upload progress: %r" % (ex,), 1)
|
||
if not get_vst:
|
||
vstate = {}
|
||
vs = {
|
||
"scanning": None,
|
||
"hashq": None,
|
||
"tagq": None,
|
||
"mtpq": None,
|
||
"dbwt": None,
|
||
}
|
||
|
||
assert vstate.items and vs # type: ignore # !rm
|
||
|
||
dls = dl_list = []
|
||
if self.conn.hsrv.tdls:
|
||
zi = self.args.dl_list
|
||
if zi == 2 or (zi == 1 and self.avol):
|
||
dl_list = self.get_dls()
|
||
for t0, t1, sent, sz, vp, dl_id, uname in dl_list:
|
||
td = max(0.1, now - t0)
|
||
rd, fn = vsplit(vp)
|
||
if not rd:
|
||
rd = "/"
|
||
erd = quotep(rd)
|
||
rds = rd.replace("/", " / ")
|
||
spd = humansize(sent / td, True) + "/s"
|
||
hsent = humansize(sent, True)
|
||
idle = s2hms(now - t1, True)
|
||
usr = "%s @%s" % (dl_id, uname) if dl_id else uname
|
||
if sz and sent and td:
|
||
eta = s2hms((sz - sent) / (sent / td), True)
|
||
perc = int(100 * sent / sz)
|
||
else:
|
||
eta = perc = "--"
|
||
|
||
fn = html_escape(fn) if fn else self.conn.hsrv.iiam
|
||
dls.append((perc, hsent, spd, eta, idle, usr, erd, rds, fn))
|
||
|
||
fmt = self.uparam.get("ls", "")
|
||
if not fmt and (self.ua.startswith("curl/") or self.ua.startswith("fetch")):
|
||
fmt = "v"
|
||
|
||
if fmt in ["v", "t", "txt"]:
|
||
if self.uname == "*":
|
||
txt = "howdy stranger (you're not logged in)"
|
||
else:
|
||
txt = "welcome back {}".format(self.uname)
|
||
|
||
if vstate:
|
||
txt += "\nstatus:"
|
||
for k in ["scanning", "hashq", "tagq", "mtpq", "dbwt"]:
|
||
txt += " {}({})".format(k, vs[k])
|
||
|
||
if ups:
|
||
txt += "\n\nincoming files:"
|
||
for zt in ups:
|
||
txt += "\n%s" % (", ".join((str(x) for x in zt)),)
|
||
txt += "\n"
|
||
|
||
if dls:
|
||
txt += "\n\nactive downloads:"
|
||
for zt in dls:
|
||
txt += "\n%s" % (", ".join((str(x) for x in zt)),)
|
||
txt += "\n"
|
||
|
||
if rvol:
|
||
txt += "\nyou can browse:"
|
||
for v in rvol:
|
||
txt += "\n " + v
|
||
|
||
if wvol:
|
||
txt += "\nyou can upload to:"
|
||
for v in wvol:
|
||
txt += "\n " + v
|
||
|
||
zb = txt.encode("utf-8", "replace") + b"\n"
|
||
self.reply(zb, mime="text/plain; charset=utf-8")
|
||
return True
|
||
|
||
html = self.j2s(
|
||
"splash",
|
||
this=self,
|
||
qvpath=quotep(self.vpaths) + self.ourlq(),
|
||
rvol=rvol,
|
||
wvol=wvol,
|
||
avol=avol,
|
||
in_shr=self.args.shr and self.vpath.startswith(self.args.shr1),
|
||
vstate=vstate,
|
||
dls=dls,
|
||
ups=ups,
|
||
scanning=vs["scanning"],
|
||
hashq=vs["hashq"],
|
||
tagq=vs["tagq"],
|
||
mtpq=vs["mtpq"],
|
||
dbwt=vs["dbwt"],
|
||
url_suf=suf,
|
||
k304=self.k304(),
|
||
no304=self.no304(),
|
||
k304vis=self.args.k304 > 0,
|
||
no304vis=self.args.no304 > 0,
|
||
ver=S_VERSION if self.args.ver else "",
|
||
chpw=self.args.chpw and self.uname != "*",
|
||
ahttps="" if self.is_https else "https://" + self.host + self.req,
|
||
)
|
||
self.reply(html.encode("utf-8"))
|
||
return True
|
||
|
||
def setck(self) -> bool:
|
||
k, v = self.uparam["setck"].split("=", 1)
|
||
t = 0 if v in ("", "x") else 86400 * 299
|
||
ck = gencookie(k, v, self.args.R, False, t)
|
||
self.out_headerlist.append(("Set-Cookie", ck))
|
||
if "cc" in self.ouparam:
|
||
self.redirect("", "?h#cc")
|
||
else:
|
||
self.reply(b"o7\n")
|
||
return True
|
||
|
||
def set_cfg_reset(self) -> bool:
|
||
for k in ALL_COOKIES:
|
||
if k not in self.cookies:
|
||
continue
|
||
cookie = gencookie(k, "x", self.args.R, False)
|
||
self.out_headerlist.append(("Set-Cookie", cookie))
|
||
|
||
self.redirect("", "?h#cc")
|
||
return True
|
||
|
||
def tx_404(self, is_403: bool = False) -> bool:
|
||
rc = 404
|
||
if self.args.vague_403:
|
||
t = '<h1 id="n">404 not found ┐( ´ -`)┌</h1><p id="o">or maybe you don\'t have access -- try a password or <a href="{}/?h">go home</a></p>'
|
||
pt = "404 not found ┐( ´ -`)┌ (or maybe you don't have access -- try a password)"
|
||
elif is_403:
|
||
t = '<h1 id="p">403 forbiddena ~┻━┻</h1><p id="q">use a password or <a href="{}/?h">go home</a></p>'
|
||
pt = "403 forbiddena ~┻━┻ (you'll have to log in)"
|
||
rc = 403
|
||
else:
|
||
t = '<h1 id="n">404 not found ┐( ´ -`)┌</h1><p><a id="r" href="{}/?h">go home</a></p>'
|
||
pt = "404 not found ┐( ´ -`)┌"
|
||
|
||
if self.ua.startswith("curl/") or self.ua.startswith("fetch"):
|
||
pt = "# acct: %s\n%s\n" % (self.uname, pt)
|
||
self.reply(pt.encode("utf-8"), status=rc)
|
||
return True
|
||
|
||
if "th" in self.ouparam and str(self.ouparam["th"])[:1] in "jw":
|
||
return self.tx_svg("e" + pt[:3])
|
||
|
||
# most webdav clients will not send credentials until they
|
||
# get 401'd, so send a challenge if we're Absolutely Sure
|
||
# that the client is not a graphical browser
|
||
if (
|
||
rc == 403
|
||
and self.uname == "*"
|
||
and "sec-fetch-site" not in self.headers
|
||
and (
|
||
not self.ua.startswith("Mozilla/")
|
||
or (self.args.dav_ua1 and self.args.dav_ua1.search(self.ua))
|
||
)
|
||
):
|
||
rc = 401
|
||
self.out_headers["WWW-Authenticate"] = 'Basic realm="a"'
|
||
|
||
t = t.format(self.args.SR)
|
||
qv = quotep(self.vpaths) + self.ourlq()
|
||
html = self.j2s(
|
||
"splash",
|
||
this=self,
|
||
qvpath=qv,
|
||
msg=t,
|
||
in_shr=self.args.shr and self.vpath.startswith(self.args.shr1),
|
||
ahttps="" if self.is_https else "https://" + self.host + self.req,
|
||
)
|
||
self.reply(html.encode("utf-8"), status=rc)
|
||
return True
|
||
|
||
def on40x(self, mods: list[str], vn: VFS, rem: str) -> str:
|
||
for mpath in mods:
|
||
try:
|
||
mod = loadpy(mpath, self.args.hot_handlers)
|
||
except Exception as ex:
|
||
self.log("import failed: {!r}".format(ex))
|
||
continue
|
||
|
||
ret = mod.main(self, vn, rem)
|
||
if ret:
|
||
return ret.lower()
|
||
|
||
return "" # unhandled / fallthrough
|
||
|
||
def scanvol(self) -> bool:
|
||
if not self.can_admin:
|
||
raise Pebkac(403, "'scanvol' not allowed for user " + self.uname)
|
||
|
||
if self.args.no_rescan:
|
||
raise Pebkac(403, "the rescan feature is disabled in server config")
|
||
|
||
vn, _ = self.asrv.vfs.get(self.vpath, self.uname, True, True)
|
||
|
||
args = [self.asrv.vfs.all_vols, [vn.vpath], False, True]
|
||
|
||
x = self.conn.hsrv.broker.ask("up2k.rescan", *args)
|
||
err = x.get()
|
||
if not err:
|
||
self.redirect("", "?h")
|
||
return True
|
||
|
||
raise Pebkac(500, err)
|
||
|
||
def handle_reload(self) -> bool:
|
||
act = self.uparam.get("reload")
|
||
if act != "cfg":
|
||
raise Pebkac(400, "only config files ('cfg') can be reloaded rn")
|
||
|
||
if not self.avol:
|
||
raise Pebkac(403, "'reload' not allowed for user " + self.uname)
|
||
|
||
if self.args.no_reload:
|
||
raise Pebkac(403, "the reload feature is disabled in server config")
|
||
|
||
x = self.conn.hsrv.broker.ask("reload", True, True)
|
||
return self.redirect("", "?h", x.get(), "return to", False)
|
||
|
||
def tx_stack(self) -> bool:
|
||
if not self.avol and not [x for x in self.wvol if x in self.rvol]:
|
||
raise Pebkac(403, "'stack' not allowed for user " + self.uname)
|
||
|
||
if self.args.no_stack:
|
||
raise Pebkac(403, "the stackdump feature is disabled in server config")
|
||
|
||
ret = "<pre>{}\n{}".format(time.time(), html_escape(alltrace()))
|
||
self.reply(ret.encode("utf-8"))
|
||
return True
|
||
|
||
def tx_tree(self) -> bool:
|
||
top = self.uparam["tree"] or ""
|
||
dst = self.vpath
|
||
if top in [".", ".."]:
|
||
top = undot(self.vpath + "/" + top)
|
||
|
||
if top == dst:
|
||
dst = ""
|
||
elif top:
|
||
if not dst.startswith(top + "/"):
|
||
raise Pebkac(422, "arg funk")
|
||
|
||
dst = dst[len(top) + 1 :]
|
||
|
||
ret = self.gen_tree(top, dst, self.uparam.get("k", ""))
|
||
if self.is_vproxied and not self.uparam["tree"]:
|
||
# uparam is '' on initial load, which is
|
||
# the only time we gotta fill in the blanks
|
||
parents = self.args.R.split("/")
|
||
for parent in reversed(parents):
|
||
ret = {"k%s" % (parent,): ret, "a": []}
|
||
|
||
zs = json.dumps(ret)
|
||
self.reply(zs.encode("utf-8"), mime="application/json")
|
||
return True
|
||
|
||
def gen_tree(self, top: str, target: str, dk: str) -> dict[str, Any]:
|
||
ret: dict[str, Any] = {}
|
||
excl = None
|
||
if target:
|
||
excl, target = (target.split("/", 1) + [""])[:2]
|
||
sub = self.gen_tree("/".join([top, excl]).strip("/"), target, dk)
|
||
ret["k" + quotep(excl)] = sub
|
||
|
||
vfs = self.asrv.vfs
|
||
dk_sz = False
|
||
if dk:
|
||
vn, rem = vfs.get(top, self.uname, False, False)
|
||
if vn.flags.get("dks") and self._use_dirkey(vn, vn.canonical(rem)):
|
||
dk_sz = vn.flags.get("dk")
|
||
|
||
dots = False
|
||
fsroot = ""
|
||
try:
|
||
vn, rem = vfs.get(top, self.uname, not dk_sz, False)
|
||
fsroot, vfs_ls, vfs_virt = vn.ls(
|
||
rem,
|
||
self.uname,
|
||
not self.args.no_scandir,
|
||
[[True, False], [False, True]],
|
||
)
|
||
dots = self.uname in vn.axs.udot
|
||
dk_sz = vn.flags.get("dk")
|
||
except:
|
||
dk_sz = None
|
||
vfs_ls = []
|
||
vfs_virt = {}
|
||
for v in self.rvol:
|
||
d1, d2 = v.rsplit("/", 1) if "/" in v else ["", v]
|
||
if d1 == top:
|
||
vfs_virt[d2] = vfs # typechk, value never read
|
||
|
||
dirs = [x[0] for x in vfs_ls if stat.S_ISDIR(x[1].st_mode)]
|
||
|
||
if not dots or "dots" not in self.uparam:
|
||
dirs = exclude_dotfiles(dirs)
|
||
|
||
dirs = [quotep(x) for x in dirs if x != excl]
|
||
|
||
if dk_sz and fsroot:
|
||
kdirs = []
|
||
for dn in dirs:
|
||
ap = os.path.join(fsroot, dn)
|
||
zs = self.gen_fk(2, self.args.dk_salt, ap, 0, 0)[:dk_sz]
|
||
kdirs.append(dn + "?k=" + zs)
|
||
dirs = kdirs
|
||
|
||
for x in vfs_virt:
|
||
if x != excl:
|
||
try:
|
||
dvn, drem = vfs.get(vjoin(top, x), self.uname, True, False)
|
||
bos.stat(dvn.canonical(drem, False))
|
||
except:
|
||
x += "\n"
|
||
dirs.append(x)
|
||
|
||
ret["a"] = dirs
|
||
return ret
|
||
|
||
def get_dls(self) -> list[list[Any]]:
|
||
ret = []
|
||
dls = self.conn.hsrv.tdls
|
||
enshare = self.args.shr
|
||
shrs = enshare[1:]
|
||
for dl_id, (t0, sz, vn, vp, uname) in self.conn.hsrv.tdli.items():
|
||
t1, sent = dls[dl_id]
|
||
if sent > 0x100000: # 1m; buffers 2~4
|
||
sent -= 0x100000
|
||
if self.uname not in vn.axs.uread:
|
||
vp = ""
|
||
elif self.uname not in vn.axs.udot and (vp.startswith(".") or "/." in vp):
|
||
vp = ""
|
||
elif (
|
||
enshare
|
||
and vp.startswith(shrs)
|
||
and self.uname != vn.shr_owner
|
||
and self.uname not in vn.axs.uadmin
|
||
and self.uname not in self.args.shr_adm
|
||
and not dl_id.startswith(self.ip + ":")
|
||
):
|
||
vp = ""
|
||
if self.uname not in vn.axs.uadmin:
|
||
dl_id = uname = ""
|
||
|
||
ret.append([t0, t1, sent, sz, vp, dl_id, uname])
|
||
return ret
|
||
|
||
def tx_dls(self) -> bool:
|
||
ret = [
|
||
{
|
||
"t0": x[0],
|
||
"t1": x[1],
|
||
"sent": x[2],
|
||
"size": x[3],
|
||
"path": x[4],
|
||
"conn": x[5],
|
||
"uname": x[6],
|
||
}
|
||
for x in self.get_dls()
|
||
]
|
||
zs = json.dumps(ret, separators=(",\n", ": "))
|
||
self.reply(zs.encode("utf-8", "replace"), mime="application/json")
|
||
return True
|
||
|
||
def tx_ups(self) -> bool:
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; unpost is disabled")
|
||
raise Pebkac(500, "server busy, cannot unpost; please retry in a bit")
|
||
|
||
zs = self.uparam.get("filter") or ""
|
||
filt = re.compile(zs, re.I) if zs else None
|
||
lm = "ups %r" % (zs,)
|
||
|
||
if self.args.shr and self.vpath.startswith(self.args.shr1):
|
||
shr_dbv, shr_vrem = self.vn.get_dbv(self.rem)
|
||
else:
|
||
shr_dbv = None
|
||
|
||
ret: list[dict[str, Any]] = []
|
||
t0 = time.time()
|
||
lim = time.time() - self.args.unpost
|
||
fk_vols = {
|
||
vol: (vol.flags["fk"], 2 if "fka" in vol.flags else 1)
|
||
for vp, vol in self.asrv.vfs.all_vols.items()
|
||
if "fk" in vol.flags
|
||
and (self.uname in vol.axs.uread or self.uname in vol.axs.upget)
|
||
}
|
||
|
||
bad_xff = hasattr(self, "bad_xff")
|
||
if bad_xff:
|
||
allvols = []
|
||
t = "will not return list of recent uploads" + BADXFF
|
||
self.log(t, 1)
|
||
if self.avol:
|
||
raise Pebkac(500, t)
|
||
|
||
x = self.conn.hsrv.broker.ask(
|
||
"up2k.get_unfinished_by_user", self.uname, "" if bad_xff else self.ip
|
||
)
|
||
uret = x.get()
|
||
|
||
if not self.args.unpost:
|
||
allvols = []
|
||
else:
|
||
allvols = list(self.asrv.vfs.all_vols.values())
|
||
|
||
allvols = [
|
||
x
|
||
for x in allvols
|
||
if "e2d" in x.flags
|
||
and ("*" in x.axs.uwrite or self.uname in x.axs.uwrite or x == shr_dbv)
|
||
]
|
||
|
||
for vol in allvols:
|
||
cur = idx.get_cur(vol)
|
||
if not cur:
|
||
continue
|
||
|
||
nfk, fk_alg = fk_vols.get(vol) or (0, 0)
|
||
|
||
n = 2000
|
||
q = "select sz, rd, fn, at from up where ip=? and at>? order by at desc"
|
||
for sz, rd, fn, at in cur.execute(q, (self.ip, lim)):
|
||
vp = "/" + "/".join(x for x in [vol.vpath, rd, fn] if x)
|
||
if filt and not filt.search(vp):
|
||
continue
|
||
|
||
n -= 1
|
||
if not n:
|
||
break
|
||
|
||
rv = {"vp": vp, "sz": sz, "at": at, "nfk": nfk}
|
||
if nfk:
|
||
rv["ap"] = vol.canonical(vjoin(rd, fn))
|
||
rv["fk_alg"] = fk_alg
|
||
|
||
ret.append(rv)
|
||
if len(ret) > 3000:
|
||
ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore
|
||
ret = ret[:2000]
|
||
|
||
ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore
|
||
|
||
if len(ret) > 2000:
|
||
ret = ret[:2000]
|
||
|
||
for rv in ret:
|
||
rv["vp"] = quotep(rv["vp"])
|
||
nfk = rv.pop("nfk")
|
||
if not nfk:
|
||
continue
|
||
|
||
alg = rv.pop("fk_alg")
|
||
ap = rv.pop("ap")
|
||
try:
|
||
st = bos.stat(ap)
|
||
except:
|
||
continue
|
||
|
||
fk = self.gen_fk(
|
||
alg, self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino
|
||
)
|
||
rv["vp"] += "?k=" + fk[:nfk]
|
||
|
||
if shr_dbv:
|
||
# translate vpaths from share-target to share-url
|
||
# to satisfy access checks
|
||
assert shr_vrem.split # type: ignore # !rm
|
||
vp_shr, vp_vfs = vroots(self.vpath, vjoin(shr_dbv.vpath, shr_vrem))
|
||
for v in ret:
|
||
vp = v["vp"]
|
||
if vp.startswith(vp_vfs):
|
||
v["vp"] = vp_shr + vp[len(vp_vfs) :]
|
||
|
||
if self.is_vproxied:
|
||
for v in ret:
|
||
v["vp"] = self.args.SR + v["vp"]
|
||
|
||
if not allvols:
|
||
ret = [{"kinshi": 1}]
|
||
|
||
jtxt = '{"u":%s,"c":%s}' % (uret, json.dumps(ret, separators=(",\n", ": ")))
|
||
zi = len(uret.split('\n"pd":')) - 1
|
||
self.log("%s #%d+%d %.2fsec" % (lm, zi, len(ret), time.time() - t0))
|
||
self.reply(jtxt.encode("utf-8", "replace"), mime="application/json")
|
||
return True
|
||
|
||
def tx_rups(self) -> bool:
|
||
if self.args.no_ups_page:
|
||
raise Pebkac(500, "listing of recent uploads is disabled in server config")
|
||
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; recent-uploads n/a")
|
||
raise Pebkac(500, "server busy, cannot list recent uploads; please retry")
|
||
|
||
sfilt = self.uparam.get("filter") or ""
|
||
filt = re.compile(sfilt, re.I) if sfilt else None
|
||
lm = "ru %r" % (sfilt,)
|
||
self.log(lm)
|
||
|
||
ret: list[dict[str, Any]] = []
|
||
t0 = time.time()
|
||
allvols = [
|
||
x
|
||
for x in self.asrv.vfs.all_vols.values()
|
||
if "e2d" in x.flags and ("*" in x.axs.uread or self.uname in x.axs.uread)
|
||
]
|
||
fk_vols = {
|
||
vol: (vol.flags["fk"], 2 if "fka" in vol.flags else 1)
|
||
for vol in allvols
|
||
if "fk" in vol.flags and "*" not in vol.axs.uread
|
||
}
|
||
|
||
for vol in allvols:
|
||
cur = idx.get_cur(vol)
|
||
if not cur:
|
||
continue
|
||
|
||
nfk, fk_alg = fk_vols.get(vol) or (0, 0)
|
||
adm = "*" in vol.axs.uadmin or self.uname in vol.axs.uadmin
|
||
dots = "*" in vol.axs.udot or self.uname in vol.axs.udot
|
||
|
||
lvl = vol.flags["ups_who"]
|
||
if not lvl:
|
||
continue
|
||
elif lvl == 1 and not adm:
|
||
continue
|
||
|
||
n = 1000
|
||
q = "select sz, rd, fn, ip, at from up where at>0 order by at desc"
|
||
for sz, rd, fn, ip, at in cur.execute(q):
|
||
vp = "/" + "/".join(x for x in [vol.vpath, rd, fn] if x)
|
||
if filt and not filt.search(vp):
|
||
continue
|
||
|
||
if not dots and "/." in vp:
|
||
continue
|
||
|
||
rv = {
|
||
"vp": vp,
|
||
"sz": sz,
|
||
"ip": ip,
|
||
"at": at,
|
||
"nfk": nfk,
|
||
"adm": adm,
|
||
}
|
||
if nfk:
|
||
rv["ap"] = vol.canonical(vjoin(rd, fn))
|
||
rv["fk_alg"] = fk_alg
|
||
|
||
ret.append(rv)
|
||
if len(ret) > 2000:
|
||
ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore
|
||
ret = ret[:1000]
|
||
|
||
n -= 1
|
||
if not n:
|
||
break
|
||
|
||
ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore
|
||
|
||
if len(ret) > 1000:
|
||
ret = ret[:1000]
|
||
|
||
for rv in ret:
|
||
rv["vp"] = quotep(rv["vp"])
|
||
nfk = rv.pop("nfk")
|
||
if not nfk:
|
||
continue
|
||
|
||
alg = rv.pop("fk_alg")
|
||
ap = rv.pop("ap")
|
||
try:
|
||
st = bos.stat(ap)
|
||
except:
|
||
continue
|
||
|
||
fk = self.gen_fk(
|
||
alg, self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino
|
||
)
|
||
rv["vp"] += "?k=" + fk[:nfk]
|
||
|
||
if self.args.ups_when:
|
||
for rv in ret:
|
||
adm = rv.pop("adm")
|
||
if not adm:
|
||
rv["ip"] = "(You)" if rv["ip"] == self.ip else "(?)"
|
||
else:
|
||
for rv in ret:
|
||
adm = rv.pop("adm")
|
||
if not adm:
|
||
rv["ip"] = "(You)" if rv["ip"] == self.ip else "(?)"
|
||
rv["at"] = 0
|
||
|
||
if self.is_vproxied:
|
||
for v in ret:
|
||
v["vp"] = self.args.SR + v["vp"]
|
||
|
||
now = time.time()
|
||
self.log("%s #%d %.2fsec" % (lm, len(ret), now - t0))
|
||
|
||
ret2 = {"now": int(now), "filter": sfilt, "ups": ret}
|
||
jtxt = json.dumps(ret2, separators=(",\n", ": "))
|
||
if "j" in self.ouparam:
|
||
self.reply(jtxt.encode("utf-8", "replace"), mime="application/json")
|
||
return True
|
||
|
||
html = self.j2s("rups", this=self, v=jtxt)
|
||
self.reply(html.encode("utf-8"), status=200)
|
||
return True
|
||
|
||
def tx_shares(self) -> bool:
|
||
if self.uname == "*":
|
||
self.loud_reply("you're not logged in")
|
||
return True
|
||
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; sharing is disabled")
|
||
raise Pebkac(500, "server busy, cannot list shares; please retry in a bit")
|
||
|
||
cur = idx.get_shr()
|
||
if not cur:
|
||
raise Pebkac(400, "huh, sharing must be disabled in the server config...")
|
||
|
||
rows = cur.execute("select * from sh").fetchall()
|
||
rows = [list(x) for x in rows]
|
||
|
||
if self.uname != self.args.shr_adm:
|
||
rows = [x for x in rows if x[5] == self.uname]
|
||
|
||
html = self.j2s(
|
||
"shares", this=self, shr=self.args.shr, rows=rows, now=int(time.time())
|
||
)
|
||
self.reply(html.encode("utf-8"), status=200)
|
||
return True
|
||
|
||
def handle_eshare(self) -> bool:
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; sharing is disabled")
|
||
raise Pebkac(500, "server busy, cannot create share; please retry in a bit")
|
||
|
||
if self.args.shr_v:
|
||
self.log("handle_eshare: " + self.req)
|
||
|
||
cur = idx.get_shr()
|
||
if not cur:
|
||
raise Pebkac(400, "huh, sharing must be disabled in the server config...")
|
||
|
||
skey = self.vpath.split("/")[-1]
|
||
|
||
rows = cur.execute("select un, t1 from sh where k = ?", (skey,)).fetchall()
|
||
un = rows[0][0] if rows and rows[0] else ""
|
||
|
||
if not un:
|
||
raise Pebkac(400, "that sharekey didn't match anything")
|
||
|
||
expiry = rows[0][1]
|
||
|
||
if un != self.uname and self.uname != self.args.shr_adm:
|
||
t = "your username (%r) does not match the sharekey's owner (%r) and you're not admin"
|
||
raise Pebkac(400, t % (self.uname, un))
|
||
|
||
reload = False
|
||
act = self.uparam["eshare"]
|
||
if act == "rm":
|
||
cur.execute("delete from sh where k = ?", (skey,))
|
||
if skey in self.asrv.vfs.nodes[self.args.shr.strip("/")].nodes:
|
||
reload = True
|
||
else:
|
||
now = time.time()
|
||
if expiry < now:
|
||
expiry = now
|
||
reload = True
|
||
expiry += int(act) * 60
|
||
cur.execute("update sh set t1 = ? where k = ?", (expiry, skey))
|
||
|
||
cur.connection.commit()
|
||
if reload:
|
||
self.conn.hsrv.broker.ask("reload", False, False).get()
|
||
self.conn.hsrv.broker.ask("up2k.wake_rescanner").get()
|
||
|
||
self.redirect(self.args.SRS + "?shares")
|
||
return True
|
||
|
||
def handle_share(self, req: dict[str, str]) -> bool:
|
||
idx = self.conn.get_u2idx()
|
||
if not idx or not hasattr(idx, "p_end"):
|
||
if not HAVE_SQLITE3:
|
||
raise Pebkac(500, "sqlite3 not found on server; sharing is disabled")
|
||
raise Pebkac(500, "server busy, cannot create share; please retry in a bit")
|
||
|
||
if self.args.shr_v:
|
||
self.log("handle_share: " + json.dumps(req, indent=4))
|
||
|
||
skey = req["k"]
|
||
vps = req["vp"]
|
||
fns = []
|
||
if len(vps) == 1:
|
||
vp = vps[0]
|
||
if not vp.endswith("/"):
|
||
vp, zs = vp.rsplit("/", 1)
|
||
fns = [zs]
|
||
else:
|
||
for zs in vps:
|
||
if zs.endswith("/"):
|
||
t = "you cannot select more than one folder, or mix files and folders in one selection"
|
||
raise Pebkac(400, t)
|
||
vp = vps[0].rsplit("/", 1)[0]
|
||
for zs in vps:
|
||
vp2, fn = zs.rsplit("/", 1)
|
||
fns.append(fn)
|
||
if vp != vp2:
|
||
t = "mismatching base paths in selection:\n [%s]\n [%s]"
|
||
raise Pebkac(400, t % (vp, vp2))
|
||
|
||
vp = vp.strip("/")
|
||
if self.is_vproxied and (vp == self.args.R or vp.startswith(self.args.RS)):
|
||
vp = vp[len(self.args.RS) :]
|
||
|
||
m = re.search(r"([^0-9a-zA-Z_-])", skey)
|
||
if m:
|
||
raise Pebkac(400, "sharekey has illegal character [%s]" % (m[1],))
|
||
|
||
if vp.startswith(self.args.shr1):
|
||
raise Pebkac(400, "yo dawg...")
|
||
|
||
cur = idx.get_shr()
|
||
if not cur:
|
||
raise Pebkac(400, "huh, sharing must be disabled in the server config...")
|
||
|
||
q = "select * from sh where k = ?"
|
||
qr = cur.execute(q, (skey,)).fetchall()
|
||
if qr and qr[0]:
|
||
self.log("sharekey taken by %r" % (qr,))
|
||
raise Pebkac(400, "sharekey [%s] is already in use" % (skey,))
|
||
|
||
# ensure user has requested perms
|
||
s_rd = "read" in req["perms"]
|
||
s_wr = "write" in req["perms"]
|
||
s_mv = "move" in req["perms"]
|
||
s_del = "delete" in req["perms"]
|
||
try:
|
||
vfs, rem = self.asrv.vfs.get(vp, self.uname, s_rd, s_wr, s_mv, s_del)
|
||
except:
|
||
raise Pebkac(400, "you dont have all the perms you tried to grant")
|
||
|
||
ap, reals, _ = vfs.ls(
|
||
rem, self.uname, not self.args.no_scandir, [[s_rd, s_wr, s_mv, s_del]]
|
||
)
|
||
rfns = set([x[0] for x in reals])
|
||
for fn in fns:
|
||
if fn not in rfns:
|
||
raise Pebkac(400, "selected file not found on disk: [%s]" % (fn,))
|
||
|
||
pw = req.get("pw") or ""
|
||
pw = self.asrv.ah.hash(pw)
|
||
now = int(time.time())
|
||
sexp = req["exp"]
|
||
exp = int(sexp) if sexp else 0
|
||
exp = now + exp * 60 if exp else 0
|
||
pr = "".join(zc for zc, zb in zip("rwmd", (s_rd, s_wr, s_mv, s_del)) if zb)
|
||
|
||
q = "insert into sh values (?,?,?,?,?,?,?,?)"
|
||
cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp))
|
||
|
||
q = "insert into sf values (?,?)"
|
||
for fn in fns:
|
||
cur.execute(q, (skey, fn))
|
||
|
||
cur.connection.commit()
|
||
self.conn.hsrv.broker.ask("reload", False, False).get()
|
||
self.conn.hsrv.broker.ask("up2k.wake_rescanner").get()
|
||
|
||
fn = quotep(fns[0]) if len(fns) == 1 else ""
|
||
|
||
surl = "created share: %s://%s%s%s%s/%s" % (
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
self.args.SR,
|
||
self.args.shr,
|
||
skey,
|
||
fn,
|
||
)
|
||
self.loud_reply(surl, status=201)
|
||
return True
|
||
|
||
def handle_rm(self, req: list[str]) -> bool:
|
||
if not req and not self.can_delete:
|
||
if self.mode == "DELETE" and self.uname == "*":
|
||
raise Pebkac(401, "authenticate") # webdav
|
||
raise Pebkac(403, "'delete' not allowed for user " + self.uname)
|
||
|
||
if self.args.no_del:
|
||
raise Pebkac(403, "the delete feature is disabled in server config")
|
||
|
||
unpost = "unpost" in self.uparam
|
||
if unpost and hasattr(self, "bad_xff"):
|
||
self.log("unpost was denied" + BADXFF, 1)
|
||
raise Pebkac(403, "the delete feature is disabled in server config")
|
||
|
||
if not req:
|
||
req = [self.vpath]
|
||
elif self.is_vproxied:
|
||
req = [x[len(self.args.SR) :] for x in req]
|
||
|
||
nlim = int(self.uparam.get("lim") or 0)
|
||
lim = [nlim, nlim] if nlim else []
|
||
|
||
x = self.conn.hsrv.broker.ask(
|
||
"up2k.handle_rm", self.uname, self.ip, req, lim, False, unpost
|
||
)
|
||
self.loud_reply(x.get())
|
||
return True
|
||
|
||
def handle_mv(self) -> bool:
|
||
# full path of new loc (incl filename)
|
||
dst = self.uparam.get("move")
|
||
|
||
if self.is_vproxied and dst and dst.startswith(self.args.SR):
|
||
dst = dst[len(self.args.RS) :]
|
||
|
||
if not dst:
|
||
raise Pebkac(400, "need dst vpath")
|
||
|
||
return self._mv(self.vpath, dst.lstrip("/"), False)
|
||
|
||
def _mv(self, vsrc: str, vdst: str, overwrite: bool) -> bool:
|
||
if self.args.no_mv:
|
||
raise Pebkac(403, "the rename/move feature is disabled in server config")
|
||
|
||
# `handle_cpmv` will catch 403 from these and raise 401
|
||
svn, srem = self.asrv.vfs.get(vsrc, self.uname, True, False, True)
|
||
dvn, drem = self.asrv.vfs.get(vdst, self.uname, False, True)
|
||
|
||
if overwrite:
|
||
dabs = dvn.canonical(drem)
|
||
if bos.path.exists(dabs):
|
||
self.log("overwriting %s" % (dabs,))
|
||
self.asrv.vfs.get(vdst, self.uname, False, True, False, True)
|
||
wunlink(self.log, dabs, dvn.flags)
|
||
|
||
x = self.conn.hsrv.broker.ask("up2k.handle_mv", self.uname, self.ip, vsrc, vdst)
|
||
self.loud_reply(x.get(), status=201)
|
||
return True
|
||
|
||
def handle_cp(self) -> bool:
|
||
# full path of new loc (incl filename)
|
||
dst = self.uparam.get("copy")
|
||
|
||
if self.is_vproxied and dst and dst.startswith(self.args.SR):
|
||
dst = dst[len(self.args.RS) :]
|
||
|
||
if not dst:
|
||
raise Pebkac(400, "need dst vpath")
|
||
|
||
return self._cp(self.vpath, dst.lstrip("/"), False)
|
||
|
||
def _cp(self, vsrc: str, vdst: str, overwrite: bool) -> bool:
|
||
if self.args.no_cp:
|
||
raise Pebkac(403, "the copy feature is disabled in server config")
|
||
|
||
svn, srem = self.asrv.vfs.get(vsrc, self.uname, True, False)
|
||
dvn, drem = self.asrv.vfs.get(vdst, self.uname, False, True)
|
||
|
||
if overwrite:
|
||
dabs = dvn.canonical(drem)
|
||
if bos.path.exists(dabs):
|
||
self.log("overwriting %s" % (dabs,))
|
||
self.asrv.vfs.get(vdst, self.uname, False, True, False, True)
|
||
wunlink(self.log, dabs, dvn.flags)
|
||
|
||
x = self.conn.hsrv.broker.ask("up2k.handle_cp", self.uname, self.ip, vsrc, vdst)
|
||
self.loud_reply(x.get(), status=201)
|
||
return True
|
||
|
||
def tx_ls(self, ls: dict[str, Any]) -> bool:
|
||
dirs = ls["dirs"]
|
||
files = ls["files"]
|
||
arg = self.uparam["ls"]
|
||
if arg in ["v", "t", "txt"]:
|
||
try:
|
||
biggest = max(ls["files"] + ls["dirs"], key=itemgetter("sz"))["sz"]
|
||
except:
|
||
biggest = 0
|
||
|
||
if arg == "v":
|
||
fmt = "\033[0;7;36m{{}}{{:>{}}}\033[0m {{}}"
|
||
nfmt = "{}"
|
||
biggest = 0
|
||
f2 = "".join(
|
||
"{}{{}}".format(x)
|
||
for x in [
|
||
"\033[7m",
|
||
"\033[27m",
|
||
"",
|
||
"\033[0;1m",
|
||
"\033[0;36m",
|
||
"\033[0m",
|
||
]
|
||
)
|
||
ctab = {"B": 6, "K": 5, "M": 1, "G": 3}
|
||
for lst in [dirs, files]:
|
||
for x in lst:
|
||
a = x["dt"].replace("-", " ").replace(":", " ").split(" ")
|
||
x["dt"] = f2.format(*list(a))
|
||
sz = humansize(x["sz"], True)
|
||
x["sz"] = "\033[0;3{}m {:>5}".format(ctab.get(sz[-1:], 0), sz)
|
||
else:
|
||
fmt = "{{}} {{:{},}} {{}}"
|
||
nfmt = "{:,}"
|
||
|
||
for x in dirs:
|
||
n = x["name"] + "/"
|
||
if arg == "v":
|
||
n = "\033[94m" + n
|
||
|
||
x["name"] = n
|
||
|
||
fmt = fmt.format(len(nfmt.format(biggest)))
|
||
retl = [
|
||
("# %s: %s" % (x, ls[x])).replace(r"</span> // <span>", " // ")
|
||
for x in ["acct", "perms", "srvinf"]
|
||
if x in ls
|
||
]
|
||
retl += [
|
||
fmt.format(x["dt"], x["sz"], x["name"])
|
||
for y in [dirs, files]
|
||
for x in y
|
||
]
|
||
ret = "\n".join(retl)
|
||
mime = "text/plain; charset=utf-8"
|
||
else:
|
||
[x.pop(k) for k in ["name", "dt"] for y in [dirs, files] for x in y]
|
||
|
||
ret = json.dumps(ls)
|
||
mime = "application/json"
|
||
|
||
ret += "\n\033[0m" if arg == "v" else "\n"
|
||
self.reply(ret.encode("utf-8", "replace"), mime=mime)
|
||
return True
|
||
|
||
def tx_browser(self) -> bool:
|
||
vpath = ""
|
||
vpnodes = [["", "/"]]
|
||
if self.vpath:
|
||
for node in self.vpath.split("/"):
|
||
if not vpath:
|
||
vpath = node
|
||
else:
|
||
vpath += "/" + node
|
||
|
||
vpnodes.append([quotep(vpath) + "/", html_escape(node, crlf=True)])
|
||
|
||
vn = self.vn
|
||
rem = self.rem
|
||
abspath = vn.dcanonical(rem)
|
||
dbv, vrem = vn.get_dbv(rem)
|
||
|
||
try:
|
||
st = bos.stat(abspath)
|
||
except:
|
||
if "on404" not in vn.flags:
|
||
return self.tx_404(not self.can_read)
|
||
|
||
ret = self.on40x(vn.flags["on404"], vn, rem)
|
||
if ret == "true":
|
||
return True
|
||
elif ret == "false":
|
||
return False
|
||
elif ret == "retry":
|
||
try:
|
||
st = bos.stat(abspath)
|
||
except:
|
||
return self.tx_404(not self.can_read)
|
||
else:
|
||
return self.tx_404(not self.can_read)
|
||
|
||
if rem.startswith(".hist/up2k.") or (
|
||
rem.endswith("/dir.txt") and rem.startswith(".hist/th/")
|
||
):
|
||
raise Pebkac(403)
|
||
|
||
e2d = "e2d" in vn.flags
|
||
e2t = "e2t" in vn.flags
|
||
|
||
add_og = "og" in vn.flags
|
||
if add_og:
|
||
if "th" in self.uparam or "raw" in self.uparam:
|
||
og_ua = add_og = False
|
||
elif self.args.og_ua:
|
||
og_ua = add_og = self.args.og_ua.search(self.ua)
|
||
else:
|
||
og_ua = False
|
||
add_og = True
|
||
og_fn = ""
|
||
|
||
if "v" in self.uparam:
|
||
add_og = og_ua = True
|
||
|
||
if "b" in self.uparam:
|
||
self.out_headers["X-Robots-Tag"] = "noindex, nofollow"
|
||
|
||
is_dir = stat.S_ISDIR(st.st_mode)
|
||
is_dk = False
|
||
fk_pass = False
|
||
icur = None
|
||
if (e2t or e2d) and (is_dir or add_og):
|
||
idx = self.conn.get_u2idx()
|
||
if idx and hasattr(idx, "p_end"):
|
||
icur = idx.get_cur(dbv)
|
||
|
||
if "k" in self.uparam or "dky" in vn.flags:
|
||
if is_dir:
|
||
use_dirkey = self._use_dirkey(vn, abspath)
|
||
use_filekey = False
|
||
else:
|
||
use_filekey = self._use_filekey(vn, abspath, st)
|
||
use_dirkey = False
|
||
else:
|
||
use_dirkey = use_filekey = False
|
||
|
||
th_fmt = self.uparam.get("th")
|
||
if self.can_read or (
|
||
self.can_get
|
||
and (use_filekey or use_dirkey or (not is_dir and "fk" not in vn.flags))
|
||
):
|
||
if th_fmt is not None:
|
||
nothumb = "dthumb" in dbv.flags
|
||
if is_dir:
|
||
vrem = vrem.rstrip("/")
|
||
if nothumb:
|
||
pass
|
||
elif icur and vrem:
|
||
q = "select fn from cv where rd=? and dn=?"
|
||
crd, cdn = vrem.rsplit("/", 1) if "/" in vrem else ("", vrem)
|
||
# no mojibake support:
|
||
try:
|
||
cfn = icur.execute(q, (crd, cdn)).fetchone()
|
||
if cfn:
|
||
fn = cfn[0]
|
||
fp = os.path.join(abspath, fn)
|
||
st = bos.stat(fp)
|
||
vrem = "{}/{}".format(vrem, fn).strip("/")
|
||
is_dir = False
|
||
except:
|
||
pass
|
||
else:
|
||
for fn in self.args.th_covers:
|
||
fp = os.path.join(abspath, fn)
|
||
try:
|
||
st = bos.stat(fp)
|
||
vrem = "{}/{}".format(vrem, fn).strip("/")
|
||
is_dir = False
|
||
break
|
||
except:
|
||
pass
|
||
|
||
if is_dir:
|
||
return self.tx_svg("folder")
|
||
|
||
thp = None
|
||
if self.thumbcli and not nothumb:
|
||
try:
|
||
thp = self.thumbcli.get(dbv, vrem, int(st.st_mtime), th_fmt)
|
||
except Pebkac as ex:
|
||
if ex.code == 500 and th_fmt[:1] in "jw":
|
||
self.log("failed to convert [%s]:\n%s" % (abspath, ex), 3)
|
||
return self.tx_svg("--error--\ncheck\nserver\nlog")
|
||
raise
|
||
|
||
if thp:
|
||
return self.tx_file(thp)
|
||
|
||
if th_fmt == "p":
|
||
raise Pebkac(404)
|
||
|
||
return self.tx_ico(rem)
|
||
|
||
elif self.can_write and th_fmt is not None:
|
||
return self.tx_svg("upload\nonly")
|
||
|
||
if not self.can_read and self.can_get and self.avn:
|
||
axs = self.avn.axs
|
||
if self.uname not in axs.uhtml:
|
||
pass
|
||
elif is_dir:
|
||
for fn in ("index.htm", "index.html"):
|
||
ap2 = os.path.join(abspath, fn)
|
||
try:
|
||
st2 = bos.stat(ap2)
|
||
except:
|
||
continue
|
||
|
||
# might as well be extra careful
|
||
if not stat.S_ISREG(st2.st_mode):
|
||
continue
|
||
|
||
if not self.trailing_slash:
|
||
return self.redirect(
|
||
self.vpath + "/", flavor="redirecting to", use302=True
|
||
)
|
||
|
||
fk_pass = True
|
||
is_dir = False
|
||
rem = vjoin(rem, fn)
|
||
vrem = vjoin(vrem, fn)
|
||
abspath = ap2
|
||
break
|
||
elif self.vpath.rsplit("/", 1)[-1] in ("index.htm", "index.html"):
|
||
fk_pass = True
|
||
|
||
if not is_dir and (self.can_read or self.can_get):
|
||
if not self.can_read and not fk_pass and "fk" in vn.flags:
|
||
if not use_filekey:
|
||
return self.tx_404(True)
|
||
|
||
if add_og and not abspath.lower().endswith(".md"):
|
||
if og_ua or self.host not in self.headers.get("referer", ""):
|
||
self.vpath, og_fn = vsplit(self.vpath)
|
||
vpath = self.vpath
|
||
vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False)
|
||
abspath = vn.dcanonical(rem)
|
||
dbv, vrem = vn.get_dbv(rem)
|
||
is_dir = stat.S_ISDIR(st.st_mode)
|
||
is_dk = True
|
||
vpnodes.pop()
|
||
|
||
if (
|
||
(abspath.endswith(".md") or self.can_delete)
|
||
and "nohtml" not in vn.flags
|
||
and (
|
||
("v" in self.uparam and abspath.endswith(".md"))
|
||
or "edit" in self.uparam
|
||
or "edit2" in self.uparam
|
||
)
|
||
):
|
||
return self.tx_md(vn, abspath)
|
||
|
||
if not add_og or not og_fn:
|
||
return self.tx_file(
|
||
abspath, None if st.st_size or "nopipe" in vn.flags else vn.realpath
|
||
)
|
||
|
||
elif is_dir and not self.can_read:
|
||
if use_dirkey:
|
||
is_dk = True
|
||
elif not self.can_write:
|
||
return self.tx_404(True)
|
||
|
||
srv_info = []
|
||
|
||
try:
|
||
if not self.args.nih:
|
||
srv_info.append(self.args.name)
|
||
except:
|
||
self.log("#wow #whoa")
|
||
|
||
if not self.args.nid:
|
||
free, total, _ = get_df(abspath, False)
|
||
if total is not None:
|
||
h1 = humansize(free or 0)
|
||
h2 = humansize(total)
|
||
srv_info.append("{} free of {}".format(h1, h2))
|
||
elif free is not None:
|
||
srv_info.append(humansize(free, True) + " free")
|
||
|
||
srv_infot = "</span> // <span>".join(srv_info)
|
||
|
||
perms = []
|
||
if self.can_read or is_dk:
|
||
perms.append("read")
|
||
if self.can_write:
|
||
perms.append("write")
|
||
if self.can_move:
|
||
perms.append("move")
|
||
if self.can_delete:
|
||
perms.append("delete")
|
||
if self.can_get:
|
||
perms.append("get")
|
||
if self.can_upget:
|
||
perms.append("upget")
|
||
if self.can_admin:
|
||
perms.append("admin")
|
||
|
||
url_suf = self.urlq({}, ["k"])
|
||
is_ls = "ls" in self.uparam
|
||
is_js = self.args.force_js or self.cookies.get("js") == "y"
|
||
|
||
if (
|
||
not is_ls
|
||
and not add_og
|
||
and (self.ua.startswith("curl/") or self.ua.startswith("fetch"))
|
||
):
|
||
self.uparam["ls"] = "v"
|
||
is_ls = True
|
||
|
||
tpl = "browser"
|
||
if "b" in self.uparam:
|
||
tpl = "browser2"
|
||
is_js = False
|
||
|
||
vf = vn.flags
|
||
ls_ret = {
|
||
"dirs": [],
|
||
"files": [],
|
||
"taglist": [],
|
||
"srvinf": srv_infot,
|
||
"acct": self.uname,
|
||
"perms": perms,
|
||
"cfg": vn.js_ls,
|
||
}
|
||
cgv = {
|
||
"ls0": None,
|
||
"acct": self.uname,
|
||
"perms": perms,
|
||
}
|
||
j2a = {
|
||
"cgv1": vn.js_htm,
|
||
"cgv": cgv,
|
||
"vpnodes": vpnodes,
|
||
"files": [],
|
||
"ls0": None,
|
||
"taglist": [],
|
||
"have_tags_idx": int(e2t),
|
||
"have_b_u": (self.can_write and self.uparam.get("b") == "u"),
|
||
"sb_lg": vn.js_ls["sb_lg"],
|
||
"url_suf": url_suf,
|
||
"title": html_escape("%s %s" % (self.args.bname, self.vpath), crlf=True),
|
||
"srv_info": srv_infot,
|
||
"dtheme": self.args.theme,
|
||
}
|
||
|
||
if self.args.js_browser:
|
||
zs = self.args.js_browser
|
||
zs += "&" if "?" in zs else "?"
|
||
j2a["js"] = zs
|
||
|
||
if self.args.css_browser:
|
||
zs = self.args.css_browser
|
||
zs += "&" if "?" in zs else "?"
|
||
j2a["css"] = zs
|
||
|
||
if not self.conn.hsrv.prism:
|
||
j2a["no_prism"] = True
|
||
|
||
if not self.can_read and not is_dk:
|
||
logues, readmes = self._add_logues(vn, abspath, None)
|
||
ls_ret["logues"] = j2a["logues"] = logues
|
||
ls_ret["readmes"] = cgv["readmes"] = readmes
|
||
|
||
if is_ls:
|
||
return self.tx_ls(ls_ret)
|
||
|
||
if not stat.S_ISDIR(st.st_mode):
|
||
return self.tx_404(True)
|
||
|
||
if "zip" in self.uparam or "tar" in self.uparam:
|
||
raise Pebkac(403)
|
||
|
||
html = self.j2s(tpl, **j2a)
|
||
self.reply(html.encode("utf-8", "replace"))
|
||
return True
|
||
|
||
for k in ["zip", "tar"]:
|
||
v = self.uparam.get(k)
|
||
if v is not None and (not add_og or not og_fn):
|
||
if is_dk and "dks" not in vn.flags:
|
||
t = "server config does not allow download-as-zip/tar; only dk is specified, need dks too"
|
||
raise Pebkac(403, t)
|
||
return self.tx_zip(k, v, self.vpath, vn, rem, [])
|
||
|
||
fsroot, vfs_ls, vfs_virt = vn.ls(
|
||
rem,
|
||
self.uname,
|
||
not self.args.no_scandir,
|
||
[[True, False], [False, True]],
|
||
lstat="lt" in self.uparam,
|
||
throw=True,
|
||
)
|
||
stats = {k: v for k, v in vfs_ls}
|
||
ls_names = [x[0] for x in vfs_ls]
|
||
ls_names.extend(list(vfs_virt.keys()))
|
||
|
||
if add_og and og_fn and not self.can_read:
|
||
ls_names = [og_fn]
|
||
is_js = True
|
||
|
||
# check for old versions of files,
|
||
# [num-backups, most-recent, hist-path]
|
||
hist: dict[str, tuple[int, float, str]] = {}
|
||
try:
|
||
if vf["md_hist"] != "s":
|
||
raise Exception()
|
||
histdir = os.path.join(fsroot, ".hist")
|
||
ptn = RE_MDV
|
||
for hfn in bos.listdir(histdir):
|
||
m = ptn.match(hfn)
|
||
if not m:
|
||
continue
|
||
|
||
fn = m.group(1) + m.group(3)
|
||
n, ts, _ = hist.get(fn, (0, 0, ""))
|
||
hist[fn] = (n + 1, max(ts, float(m.group(2))), hfn)
|
||
except:
|
||
pass
|
||
|
||
lnames = {x.lower(): x for x in ls_names}
|
||
|
||
# show dotfiles if permitted and requested
|
||
if not self.can_dot or (
|
||
"dots" not in self.uparam and (is_ls or "dots" not in self.cookies)
|
||
):
|
||
ls_names = exclude_dotfiles(ls_names)
|
||
|
||
add_dk = vf.get("dk")
|
||
add_fk = vf.get("fk")
|
||
fk_alg = 2 if "fka" in vf else 1
|
||
if add_dk:
|
||
if vf.get("dky"):
|
||
add_dk = False
|
||
else:
|
||
zs = self.gen_fk(2, self.args.dk_salt, abspath, 0, 0)[:add_dk]
|
||
ls_ret["dk"] = cgv["dk"] = zs
|
||
|
||
no_zip = bool(self._can_zip(vf))
|
||
|
||
dirs = []
|
||
files = []
|
||
ptn_hr = RE_HR
|
||
for fn in ls_names:
|
||
base = ""
|
||
href = fn
|
||
if not is_ls and not is_js and not self.trailing_slash and vpath:
|
||
base = "/" + vpath + "/"
|
||
href = base + fn
|
||
|
||
if fn in vfs_virt:
|
||
fspath = vfs_virt[fn].realpath
|
||
else:
|
||
fspath = fsroot + "/" + fn
|
||
|
||
try:
|
||
linf = stats.get(fn) or bos.lstat(fspath)
|
||
inf = bos.stat(fspath) if stat.S_ISLNK(linf.st_mode) else linf
|
||
except:
|
||
self.log("broken symlink: %r" % (fspath,))
|
||
continue
|
||
|
||
is_dir = stat.S_ISDIR(inf.st_mode)
|
||
if is_dir:
|
||
href += "/"
|
||
if no_zip:
|
||
margin = "DIR"
|
||
elif add_dk:
|
||
zs = absreal(fspath)
|
||
margin = '<a href="%s?k=%s&zip=crc" rel="nofollow">zip</a>' % (
|
||
quotep(href),
|
||
self.gen_fk(2, self.args.dk_salt, zs, 0, 0)[:add_dk],
|
||
)
|
||
else:
|
||
margin = '<a href="%s?zip=crc" rel="nofollow">zip</a>' % (
|
||
quotep(href),
|
||
)
|
||
elif fn in hist:
|
||
margin = '<a href="%s.hist/%s" rel="nofollow">#%s</a>' % (
|
||
base,
|
||
html_escape(hist[fn][2], quot=True, crlf=True),
|
||
hist[fn][0],
|
||
)
|
||
else:
|
||
margin = "-"
|
||
|
||
sz = inf.st_size
|
||
zd = datetime.fromtimestamp(max(0, linf.st_mtime), UTC)
|
||
dt = "%04d-%02d-%02d %02d:%02d:%02d" % (
|
||
zd.year,
|
||
zd.month,
|
||
zd.day,
|
||
zd.hour,
|
||
zd.minute,
|
||
zd.second,
|
||
)
|
||
|
||
if is_dir:
|
||
ext = "---"
|
||
elif "." in fn:
|
||
ext = ptn_hr.sub("@", fn.rsplit(".", 1)[1])
|
||
if len(ext) > 16:
|
||
ext = ext[:16]
|
||
else:
|
||
ext = "%"
|
||
|
||
if add_fk and not is_dir:
|
||
href = "%s?k=%s" % (
|
||
quotep(href),
|
||
self.gen_fk(
|
||
fk_alg,
|
||
self.args.fk_salt,
|
||
fspath,
|
||
sz,
|
||
0 if ANYWIN else inf.st_ino,
|
||
)[:add_fk],
|
||
)
|
||
elif add_dk and is_dir:
|
||
href = "%s?k=%s" % (
|
||
quotep(href),
|
||
self.gen_fk(2, self.args.dk_salt, fspath, 0, 0)[:add_dk],
|
||
)
|
||
else:
|
||
href = quotep(href)
|
||
|
||
item = {
|
||
"lead": margin,
|
||
"href": href,
|
||
"name": fn,
|
||
"sz": sz,
|
||
"ext": ext,
|
||
"dt": dt,
|
||
"ts": int(linf.st_mtime),
|
||
}
|
||
if is_dir:
|
||
dirs.append(item)
|
||
else:
|
||
files.append(item)
|
||
|
||
if is_dk and not vf.get("dks"):
|
||
dirs = []
|
||
|
||
if (
|
||
self.cookies.get("idxh") == "y"
|
||
and "ls" not in self.uparam
|
||
and "v" not in self.uparam
|
||
):
|
||
idx_html = set(["index.htm", "index.html"])
|
||
for item in files:
|
||
if item["name"] in idx_html:
|
||
# do full resolve in case of shadowed file
|
||
vp = vjoin(self.vpath.split("?")[0], item["name"])
|
||
vn, rem = self.asrv.vfs.get(vp, self.uname, True, False)
|
||
ap = vn.canonical(rem)
|
||
return self.tx_file(ap) # is no-cache
|
||
|
||
mte = vn.flags.get("mte", {})
|
||
add_up_at = ".up_at" in mte
|
||
is_admin = self.can_admin
|
||
tagset: set[str] = set()
|
||
rd = vrem
|
||
for fe in files if icur else []:
|
||
assert icur # !rm
|
||
fn = fe["name"]
|
||
erd_efn = (rd, fn)
|
||
q = "select mt.k, mt.v from up inner join mt on mt.w = substr(up.w,1,16) where up.rd = ? and up.fn = ? and +mt.k != 'x'"
|
||
try:
|
||
r = icur.execute(q, erd_efn)
|
||
except Exception as ex:
|
||
if "database is locked" in str(ex):
|
||
break
|
||
|
||
try:
|
||
erd_efn = s3enc(idx.mem_cur, rd, fn)
|
||
r = icur.execute(q, erd_efn)
|
||
except:
|
||
self.log("tag read error, %r / %r\n%s" % (rd, fn, min_ex()))
|
||
break
|
||
|
||
tags = {k: v for k, v in r}
|
||
|
||
if is_admin:
|
||
q = "select ip, at from up where rd=? and fn=?"
|
||
try:
|
||
zs1, zs2 = icur.execute(q, erd_efn).fetchone()
|
||
if zs1:
|
||
tags["up_ip"] = zs1
|
||
if zs2:
|
||
tags[".up_at"] = zs2
|
||
except:
|
||
pass
|
||
elif add_up_at:
|
||
q = "select at from up where rd=? and fn=?"
|
||
try:
|
||
(zs1,) = icur.execute(q, erd_efn).fetchone()
|
||
if zs1:
|
||
tags[".up_at"] = zs1
|
||
except:
|
||
pass
|
||
|
||
_ = [tagset.add(k) for k in tags]
|
||
fe["tags"] = tags
|
||
|
||
if icur:
|
||
for fe in dirs:
|
||
fe["tags"] = ODict()
|
||
|
||
lmte = list(mte)
|
||
if self.can_admin:
|
||
lmte.extend(("up_ip", ".up_at"))
|
||
|
||
if "nodirsz" not in vf:
|
||
tagset.add(".files")
|
||
vdir = "%s/" % (rd,) if rd else ""
|
||
q = "select sz, nf from ds where rd=? limit 1"
|
||
for fe in dirs:
|
||
try:
|
||
hit = icur.execute(q, (vdir + fe["name"],)).fetchone()
|
||
(fe["sz"], fe["tags"][".files"]) = hit
|
||
except:
|
||
pass # 404 or mojibake
|
||
|
||
taglist = [k for k in lmte if k in tagset]
|
||
else:
|
||
taglist = list(tagset)
|
||
|
||
logues, readmes = self._add_logues(vn, abspath, lnames)
|
||
ls_ret["logues"] = j2a["logues"] = logues
|
||
ls_ret["readmes"] = cgv["readmes"] = readmes
|
||
|
||
if (
|
||
not files
|
||
and not dirs
|
||
and not readmes[0]
|
||
and not readmes[1]
|
||
and not logues[0]
|
||
and not logues[1]
|
||
):
|
||
logues[1] = "this folder is empty"
|
||
|
||
if "descript.ion" in lnames and os.path.isfile(
|
||
os.path.join(abspath, lnames["descript.ion"])
|
||
):
|
||
rem = []
|
||
with open(os.path.join(abspath, lnames["descript.ion"]), "rb") as f:
|
||
for bln in [x.strip() for x in f]:
|
||
try:
|
||
if bln.endswith(b"\x04\xc2"):
|
||
# multiline comment; replace literal r"\n" with " // "
|
||
bln = bln.replace(br"\\n", b" // ")[:-2]
|
||
ln = bln.decode("utf-8", "replace")
|
||
if ln.startswith('"'):
|
||
fn, desc = ln.split('" ', 1)
|
||
fn = fn[1:]
|
||
else:
|
||
fn, desc = ln.split(" ", 1)
|
||
fe = next(
|
||
(x for x in files if x["name"].lower() == fn.lower()), None
|
||
)
|
||
if fe:
|
||
fe["tags"]["descript.ion"] = desc
|
||
else:
|
||
t = "<li><code>%s</code> %s</li>"
|
||
rem.append(t % (html_escape(fn), html_escape(desc)))
|
||
except:
|
||
pass
|
||
if "descript.ion" not in taglist:
|
||
taglist.insert(0, "descript.ion")
|
||
if rem and not logues[1]:
|
||
t = "<h3>descript.ion</h3><ul>\n"
|
||
logues[1] = t + "\n".join(rem) + "</ul>"
|
||
|
||
if is_ls:
|
||
ls_ret["dirs"] = dirs
|
||
ls_ret["files"] = files
|
||
ls_ret["taglist"] = taglist
|
||
return self.tx_ls(ls_ret)
|
||
|
||
doc = self.uparam.get("doc") if self.can_read else None
|
||
if doc:
|
||
zp = self.args.ua_nodoc
|
||
if zp and zp.search(self.ua):
|
||
t = "this URL contains no valuable information for bots/crawlers"
|
||
raise Pebkac(403, t)
|
||
j2a["docname"] = doc
|
||
doctxt = None
|
||
dfn = lnames.get(doc.lower())
|
||
if dfn and dfn != doc:
|
||
# found Foo but want FOO
|
||
dfn = next((x for x in files if x["name"] == doc), None)
|
||
if dfn:
|
||
docpath = os.path.join(abspath, doc)
|
||
sz = bos.path.getsize(docpath)
|
||
if sz < 1024 * self.args.txt_max:
|
||
doctxt = read_utf8(self.log, fsenc(docpath), False)
|
||
if doc.lower().endswith(".md") and "exp" in vn.flags:
|
||
doctxt = self._expand(doctxt, vn.flags.get("exp_md") or [])
|
||
else:
|
||
self.log("doc 2big: %r" % (doc,), 6)
|
||
doctxt = "( size of textfile exceeds serverside limit )"
|
||
else:
|
||
self.log("doc 404: %r" % (doc,), 6)
|
||
doctxt = "( textfile not found )"
|
||
|
||
if doctxt is not None:
|
||
j2a["doc"] = doctxt
|
||
|
||
for d in dirs:
|
||
d["name"] += "/"
|
||
|
||
dirs.sort(key=itemgetter("name"))
|
||
|
||
if is_js:
|
||
j2a["ls0"] = cgv["ls0"] = {
|
||
"dirs": dirs,
|
||
"files": files,
|
||
"taglist": taglist,
|
||
}
|
||
j2a["files"] = []
|
||
else:
|
||
j2a["files"] = dirs + files
|
||
|
||
j2a["taglist"] = taglist
|
||
|
||
if add_og and "raw" not in self.uparam:
|
||
j2a["this"] = self
|
||
cgv["og_fn"] = og_fn
|
||
if og_fn and vn.flags.get("og_tpl"):
|
||
tpl = vn.flags["og_tpl"]
|
||
if "EXT" in tpl:
|
||
zs = og_fn.split(".")[-1].lower()
|
||
tpl2 = tpl.replace("EXT", zs)
|
||
if os.path.exists(tpl2):
|
||
tpl = tpl2
|
||
with self.conn.hsrv.mutex:
|
||
if tpl not in self.conn.hsrv.j2:
|
||
tdir, tname = os.path.split(tpl)
|
||
j2env = jinja2.Environment()
|
||
j2env.loader = jinja2.FileSystemLoader(tdir)
|
||
self.conn.hsrv.j2[tpl] = j2env.get_template(tname)
|
||
thumb = ""
|
||
is_pic = is_vid = is_au = False
|
||
for fn in self.args.th_coversd:
|
||
if fn in lnames:
|
||
thumb = lnames[fn]
|
||
break
|
||
if og_fn:
|
||
ext = og_fn.split(".")[-1].lower()
|
||
if self.thumbcli and ext in self.thumbcli.thumbable:
|
||
is_pic = (
|
||
ext in self.thumbcli.fmt_pil
|
||
or ext in self.thumbcli.fmt_vips
|
||
or ext in self.thumbcli.fmt_ffi
|
||
)
|
||
is_vid = ext in self.thumbcli.fmt_ffv
|
||
is_au = ext in self.thumbcli.fmt_ffa
|
||
if not thumb or not is_au:
|
||
thumb = og_fn
|
||
file = next((x for x in files if x["name"] == og_fn), None)
|
||
else:
|
||
file = None
|
||
|
||
url_base = "%s://%s/%s" % (
|
||
"https" if self.is_https else "http",
|
||
self.host,
|
||
self.args.RS + quotep(vpath),
|
||
)
|
||
j2a["og_is_pic"] = is_pic
|
||
j2a["og_is_vid"] = is_vid
|
||
j2a["og_is_au"] = is_au
|
||
if thumb:
|
||
fmt = vn.flags.get("og_th", "j")
|
||
th_base = ujoin(url_base, quotep(thumb))
|
||
query = "th=%s&cache" % (fmt,)
|
||
if use_filekey:
|
||
query += "&k=" + self.uparam["k"]
|
||
query = ub64enc(query.encode("utf-8")).decode("ascii")
|
||
# discord looks at file extension, not content-type...
|
||
query += "/th.jpg" if "j" in fmt else "/th.webp"
|
||
j2a["og_thumb"] = "%s/.uqe/%s" % (th_base, query)
|
||
|
||
j2a["og_fn"] = og_fn
|
||
j2a["og_file"] = file
|
||
if og_fn:
|
||
og_fn_q = quotep(og_fn)
|
||
query = "raw"
|
||
if use_filekey:
|
||
query += "&k=" + self.uparam["k"]
|
||
query = ub64enc(query.encode("utf-8")).decode("ascii")
|
||
query += "/%s" % (og_fn_q,)
|
||
j2a["og_url"] = ujoin(url_base, og_fn_q)
|
||
j2a["og_raw"] = j2a["og_url"] + "/.uqe/" + query
|
||
else:
|
||
j2a["og_url"] = j2a["og_raw"] = url_base
|
||
|
||
if not vn.flags.get("og_no_head"):
|
||
ogh = {"twitter:card": "summary"}
|
||
|
||
title = str(vn.flags.get("og_title") or "")
|
||
|
||
if thumb:
|
||
ogh["og:image"] = j2a["og_thumb"]
|
||
|
||
zso = vn.flags.get("og_desc") or ""
|
||
if zso != "-":
|
||
ogh["og:description"] = str(zso)
|
||
|
||
zs = vn.flags.get("og_site") or self.args.name
|
||
if zs not in ("", "-"):
|
||
ogh["og:site_name"] = zs
|
||
|
||
tagmap = {}
|
||
if is_au:
|
||
title = str(vn.flags.get("og_title_a") or "")
|
||
ogh["og:type"] = "music.song"
|
||
ogh["og:audio"] = j2a["og_raw"]
|
||
tagmap = {
|
||
"artist": "og:music:musician",
|
||
"album": "og:music:album",
|
||
".dur": "og:music:duration",
|
||
}
|
||
elif is_vid:
|
||
title = str(vn.flags.get("og_title_v") or "")
|
||
ogh["og:type"] = "video.other"
|
||
ogh["og:video"] = j2a["og_raw"]
|
||
tagmap = {
|
||
"title": "og:title",
|
||
".dur": "og:video:duration",
|
||
}
|
||
elif is_pic:
|
||
title = str(vn.flags.get("og_title_i") or "")
|
||
ogh["twitter:card"] = "summary_large_image"
|
||
ogh["twitter:image"] = ogh["og:image"] = j2a["og_raw"]
|
||
|
||
try:
|
||
for k, v in file["tags"].items():
|
||
zs = "{{ %s }}" % (k,)
|
||
title = title.replace(zs, str(v))
|
||
except:
|
||
pass
|
||
title = re.sub(r"\{\{ [^}]+ \}\}", "", title)
|
||
while title.startswith(" - "):
|
||
title = title[3:]
|
||
while title.endswith(" - "):
|
||
title = title[:3]
|
||
|
||
if vn.flags.get("og_s_title") or not title:
|
||
title = str(vn.flags.get("og_title") or "")
|
||
|
||
for tag, hname in tagmap.items():
|
||
try:
|
||
v = file["tags"][tag]
|
||
if not v:
|
||
continue
|
||
ogh[hname] = int(v) if tag == ".dur" else v
|
||
except:
|
||
pass
|
||
|
||
ogh["og:title"] = title
|
||
|
||
oghs = [
|
||
'\t<meta property="%s" content="%s">'
|
||
% (k, html_escape(str(v), True, True))
|
||
for k, v in ogh.items()
|
||
]
|
||
zs = self.html_head + "\n%s\n" % ("\n".join(oghs),)
|
||
self.html_head = zs.replace("\n\n", "\n")
|
||
|
||
html = self.j2s(tpl, **j2a)
|
||
self.reply(html.encode("utf-8", "replace"))
|
||
return True
|